diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 915b55c2..ed91a3ef 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -282,11 +282,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial { } /** - * Build system prompt configuration based on autoLoadClaudeMd setting. - * When autoLoadClaudeMd is true: - * - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading - * - If there's a custom systemPrompt, appends it to the preset - * - Sets settingSources to ['project'] for SDK to load CLAUDE.md files + * Build system prompt and settingSources based on two independent settings: + * - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt + * - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files + * + * These combine independently (4 possible states): + * 1. Both ON: preset + settingSources (full Claude Code experience) + * 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading) + * 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources + * 4. Both OFF: plain string only * * @param config - The SDK options config * @returns Object with systemPrompt and settingSources for SDK options @@ -295,27 +299,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): { systemPrompt?: string | SystemPromptConfig; settingSources?: Array<'user' | 'project' | 'local'>; } { - if (!config.autoLoadClaudeMd) { - // Standard mode - just pass through the system prompt as-is - return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}; - } - - // Auto-load CLAUDE.md mode - use preset with settingSources const result: { - systemPrompt: SystemPromptConfig; - settingSources: Array<'user' | 'project' | 'local'>; - } = { - systemPrompt: { + systemPrompt?: string | SystemPromptConfig; + settingSources?: Array<'user' | 'project' | 'local'>; + } = {}; + + // Determine system prompt format based on useClaudeCodeSystemPrompt + if (config.useClaudeCodeSystemPrompt) { + // Use Claude Code's built-in system prompt as the base + const presetConfig: SystemPromptConfig = { type: 'preset', preset: 'claude_code', - }, - // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings - settingSources: ['user', 'project'], - }; + }; + // If there's a custom system prompt, append it to the preset + if (config.systemPrompt) { + presetConfig.append = config.systemPrompt; + } + result.systemPrompt = presetConfig; + } else { + // Standard mode - just pass through the system prompt as-is + if (config.systemPrompt) { + result.systemPrompt = config.systemPrompt; + } + } - // If there's a custom system prompt, append it to the preset - if (config.systemPrompt) { - result.systemPrompt.append = config.systemPrompt; + // Determine settingSources based on autoLoadClaudeMd + if (config.autoLoadClaudeMd) { + // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings + result.settingSources = ['user', 'project']; } return result; @@ -323,12 +334,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): { /** * System prompt configuration for SDK options - * When using preset mode with claude_code, CLAUDE.md files are automatically loaded + * The 'claude_code' preset provides the system prompt only — it does NOT auto-load + * CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by + * settingSources (set via autoLoadClaudeMd). These two settings are orthogonal. */ export interface SystemPromptConfig { - /** Use preset mode with claude_code to enable CLAUDE.md auto-loading */ + /** Use preset mode to select the base system prompt */ type: 'preset'; - /** The preset to use - 'claude_code' enables CLAUDE.md loading */ + /** The preset to use - 'claude_code' uses the Claude Code system prompt */ preset: 'claude_code'; /** Optional additional prompt to append to the preset */ append?: string; @@ -362,6 +375,9 @@ export interface CreateSdkOptionsConfig { /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ autoLoadClaudeMd?: boolean; + /** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */ + useClaudeCodeSystemPrompt?: boolean; + /** MCP servers to make available to the agent */ mcpServers?: Record; diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index d0855f94..e155ced1 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -80,6 +80,49 @@ export async function getAutoLoadClaudeMdSetting( } } +/** + * Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global. + * Falls back to global settings and defaults to true when unset. + * Returns true if settings service is not available. + * + * @param projectPath - Path to the project + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to the useClaudeCodeSystemPrompt setting value + */ +export async function getUseClaudeCodeSystemPromptSetting( + projectPath: string, + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + if (!settingsService) { + logger.info( + `${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true` + ); + return true; + } + + try { + // Check project settings first (takes precedence) + const projectSettings = await settingsService.getProjectSettings(projectPath); + if (projectSettings.useClaudeCodeSystemPrompt !== undefined) { + logger.info( + `${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}` + ); + return projectSettings.useClaudeCodeSystemPrompt; + } + + // Fall back to global settings + const globalSettings = await settingsService.getGlobalSettings(); + const result = globalSettings.useClaudeCodeSystemPrompt ?? true; + logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`); + return result; + } catch (error) { + logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error); + throw error; + } +} + /** * Get the default max turns setting from global settings. * diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 0cb9fce9..0923a626 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -33,8 +33,23 @@ const logger = createLogger('ClaudeProvider'); */ type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; -// System vars are always passed from process.env regardless of profile -const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; +// System vars are always passed from process.env regardless of profile. +// Includes filesystem, locale, and temp directory vars that the Claude CLI +// needs internally for config resolution and temp file creation. +const SYSTEM_ENV_VARS = [ + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', + 'TMPDIR', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', +]; /** * Check if the config is a ClaudeCompatibleProvider (new system) @@ -213,6 +228,8 @@ export class ClaudeProvider extends BaseProvider { env: buildEnv(providerConfig, credentials), // Pass through allowedTools if provided by caller (decided by sdk-options.ts) ...(allowedTools && { allowedTools }), + // Restrict available built-in tools if specified (tools: [] disables all tools) + ...(options.tools && { tools: options.tools }), // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index a1797a3f..27993e95 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string { return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.'; } - // Claude Code process crash + // Claude Code process crash - extract exit code for diagnostics if (rawMessage.includes('Claude Code process exited')) { - return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.'; + const exitCodeMatch = rawMessage.match(/exited with code (\d+)/); + const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown'; + logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`); + return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`; + } + + // Claude Code process killed by signal + if (rawMessage.includes('Claude Code process terminated by signal')) { + const signalMatch = rawMessage.match(/terminated by signal (\w+)/); + const signal = signalMatch ? signalMatch[1] : 'unknown'; + logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`); + return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`; } // Rate limiting diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts index c2548f24..2f47d293 100644 --- a/apps/server/src/routes/backlog-plan/generate-plan.ts +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -3,6 +3,9 @@ * * Model is configurable via phaseModels.backlogPlanningModel in settings * (defaults to Sonnet). Can be overridden per-call via model parameter. + * + * Includes automatic retry for transient CLI failures (e.g., "Claude Code + * process exited unexpectedly") to improve reliability. */ import type { EventEmitter } from '../../lib/events.js'; @@ -12,8 +15,10 @@ import { isCursorModel, stripProviderPrefix, type ThinkingLevel, + type SystemPromptPreset, } from '@automaker/types'; import { resolvePhaseModel } from '@automaker/model-resolver'; +import { getCurrentBranch } from '@automaker/git-utils'; import { FeatureLoader } from '../../services/feature-loader.js'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; @@ -27,10 +32,27 @@ import { import type { SettingsService } from '../../services/settings-service.js'; import { getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, getPromptCustomization, getPhaseModelWithOverrides, } from '../../lib/settings-helpers.js'; +/** Maximum number of retry attempts for transient CLI failures */ +const MAX_RETRIES = 2; +/** Delay between retries in milliseconds */ +const RETRY_DELAY_MS = 2000; + +/** + * Check if an error is retryable (transient CLI process failure) + */ +function isRetryableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('Claude Code process exited') || + message.includes('Claude Code process terminated by signal') + ); +} + const featureLoader = new FeatureLoader(); /** @@ -84,6 +106,53 @@ function parsePlanResponse(response: string): BacklogPlanResult { }; } +/** + * Try to parse a valid plan response without fallback behavior. + * Returns null if parsing fails. + */ +function tryParsePlanResponse(response: string): BacklogPlanResult | null { + if (!response || response.trim().length === 0) { + return null; + } + return extractJsonWithArray(response, 'changes', { logger }); +} + +/** + * Choose the most reliable response text between streamed assistant chunks + * and provider final result payload. + */ +function selectBestResponseText(accumulatedText: string, providerResultText: string): string { + const hasAccumulated = accumulatedText.trim().length > 0; + const hasProviderResult = providerResultText.trim().length > 0; + + if (!hasProviderResult) { + return accumulatedText; + } + if (!hasAccumulated) { + return providerResultText; + } + + const accumulatedParsed = tryParsePlanResponse(accumulatedText); + const providerParsed = tryParsePlanResponse(providerResultText); + + if (providerParsed && !accumulatedParsed) { + logger.info('[BacklogPlan] Using provider result (parseable JSON)'); + return providerResultText; + } + if (accumulatedParsed && !providerParsed) { + logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)'); + return accumulatedText; + } + + if (providerResultText.length > accumulatedText.length) { + logger.info('[BacklogPlan] Using provider result (longer content)'); + return providerResultText; + } + + logger.info('[BacklogPlan] Keeping accumulated text (longer content)'); + return accumulatedText; +} + /** * Generate a backlog modification plan based on user prompt */ @@ -93,11 +162,40 @@ export async function generateBacklogPlan( events: EventEmitter, abortController: AbortController, settingsService?: SettingsService, - model?: string + model?: string, + branchName?: string ): Promise { try { // Load current features - const features = await featureLoader.getAll(projectPath); + const allFeatures = await featureLoader.getAll(projectPath); + + // Filter features by branch if specified (worktree-scoped backlog) + let features: Feature[]; + if (branchName) { + // Determine the primary branch so unassigned features show for the main worktree + let primaryBranch: string | null = null; + try { + primaryBranch = await getCurrentBranch(projectPath); + } catch { + // If git fails, fall back to 'main' so unassigned features are visible + // when branchName matches a common default branch name + primaryBranch = 'main'; + } + const isMainBranch = branchName === primaryBranch; + + features = allFeatures.filter((f) => { + if (!f.branchName) { + // Unassigned features belong to the main/primary worktree + return isMainBranch; + } + return f.branchName === branchName; + }); + logger.info( + `[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}` + ); + } else { + features = allFeatures; + } events.emit('backlog-plan:event', { type: 'backlog_plan_progress', @@ -162,17 +260,23 @@ export async function generateBacklogPlan( // Strip provider prefix - providers expect bare model IDs const bareModel = stripProviderPrefix(effectiveModel); - // Get autoLoadClaudeMd setting + // Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( projectPath, settingsService, '[BacklogPlan]' ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + settingsService, + '[BacklogPlan]' + ); // For Cursor models, we need to combine prompts with explicit instructions // because Cursor doesn't support systemPrompt separation like Claude SDK let finalPrompt = userPrompt; - let finalSystemPrompt: string | undefined = systemPrompt; + let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt; + let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined; if (isCursorModel(effectiveModel)) { logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions'); @@ -187,54 +291,141 @@ CRITICAL INSTRUCTIONS: ${userPrompt}`; finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt + } else if (useClaudeCodeSystemPrompt) { + // Use claude_code preset for Claude models so the SDK subprocess + // authenticates via CLI OAuth or API key the same way all other SDK calls do. + finalSystemPrompt = { + type: 'preset', + preset: 'claude_code', + append: systemPrompt, + }; + } + // Include settingSources when autoLoadClaudeMd is enabled + if (autoLoadClaudeMd) { + finalSettingSources = ['user', 'project']; } - // Execute the query - const stream = provider.executeQuery({ + // Execute the query with retry logic for transient CLI failures + const queryOptions = { prompt: finalPrompt, model: bareModel, cwd: projectPath, systemPrompt: finalSystemPrompt, maxTurns: 1, - allowedTools: [], // No tools needed for this + tools: [] as string[], // Disable all built-in tools - plan generation only needs text output abortController, - settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, - readOnly: true, // Plan generation only generates text, doesn't write files + settingSources: finalSettingSources, thinkingLevel, // Pass thinking level for extended thinking claudeCompatibleProvider, // Pass provider for alternative endpoint configuration credentials, // Pass credentials for resolving 'credentials' apiKeySource - }); + }; let responseText = ''; + let bestResponseText = ''; // Preserve best response across all retry attempts + let recoveredResult: BacklogPlanResult | null = null; + let lastError: unknown = null; - for await (const msg of stream) { + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (abortController.signal.aborted) { throw new Error('Generation aborted'); } - if (msg.type === 'assistant') { - if (msg.message?.content) { - for (const block of msg.message.content) { - if (block.type === 'text') { - responseText += block.text; + if (attempt > 0) { + logger.info( + `[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure` + ); + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`, + }); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + + let accumulatedText = ''; + let providerResultText = ''; + + try { + const stream = provider.executeQuery(queryOptions); + + for await (const msg of stream) { + if (abortController.signal.aborted) { + throw new Error('Generation aborted'); + } + + if (msg.type === 'assistant') { + if (msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + accumulatedText += block.text; + } + } } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + providerResultText = msg.result; + logger.info( + '[BacklogPlan] Received result from provider, length:', + providerResultText.length + ); + logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length); } } - } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if it's a final accumulated message (from Cursor provider) - logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length); - logger.info('[BacklogPlan] Previous responseText length:', responseText.length); - if (msg.result.length > responseText.length) { - logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)'); - responseText = msg.result; - } else { - logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)'); + + responseText = selectBestResponseText(accumulatedText, providerResultText); + + // If we got here, the stream completed successfully + lastError = null; + break; + } catch (error) { + lastError = error; + const errorMessage = error instanceof Error ? error.message : String(error); + responseText = selectBestResponseText(accumulatedText, providerResultText); + + // Preserve the best response text across all attempts so that if a retry + // crashes immediately (empty response), we can still recover from an earlier attempt + bestResponseText = selectBestResponseText(bestResponseText, responseText); + + // Claude SDK can occasionally exit non-zero after emitting a complete response. + // If we already have valid JSON, recover instead of failing the entire planning flow. + if (isRetryableError(error)) { + const parsed = tryParsePlanResponse(bestResponseText); + if (parsed) { + logger.warn( + '[BacklogPlan] Recovered from transient CLI exit using accumulated valid response' + ); + recoveredResult = parsed; + lastError = null; + break; + } + + // On final retryable failure, degrade gracefully if we have text from any attempt. + if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) { + logger.warn( + '[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse' + ); + recoveredResult = parsePlanResponse(bestResponseText); + lastError = null; + break; + } } + + // Only retry on transient CLI failures, not on user aborts or other errors + if (!isRetryableError(error) || attempt >= MAX_RETRIES) { + throw error; + } + + logger.warn( + `[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}` + ); } } + // If we exhausted retries, throw the last error + if (lastError) { + throw lastError; + } + // Parse the response - const result = parsePlanResponse(responseText); + const result = recoveredResult ?? parsePlanResponse(responseText); await saveBacklogPlan(projectPath, { savedAt: new Date().toISOString(), diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index cd67d3db..befe96e8 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js'; export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, prompt, model } = req.body as { + const { projectPath, prompt, model, branchName } = req.body as { projectPath: string; prompt: string; model?: string; + branchName?: string; }; if (!projectPath) { @@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se return; } - setRunningState(true); + const abortController = new AbortController(); + setRunningState(true, abortController); setRunningDetails({ projectPath, prompt, model, startedAt: new Date().toISOString(), }); - const abortController = new AbortController(); - setRunningState(true, abortController); // Start generation in background - // Note: generateBacklogPlan handles its own error event emission, - // so we only log here to avoid duplicate error toasts - generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model) - .catch((error) => { - // Just log - error event already emitted by generateBacklogPlan - logError(error, 'Generate backlog plan failed (background)'); - }) - .finally(() => { - setRunningState(false, null); - setRunningDetails(null); - }); + // Note: generateBacklogPlan handles its own error event emission + // and state cleanup in its finally block, so we only log here + generateBacklogPlan( + projectPath, + prompt, + events, + abortController, + settingsService, + model, + branchName + ).catch((error) => { + // Just log - error event already emitted by generateBacklogPlan + logError(error, 'Generate backlog plan failed (background)'); + }); res.json({ success: true }); } catch (error) { diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts index 018a932c..1b645d5d 100644 --- a/apps/server/src/routes/context/routes/describe-image.ts +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): { if (!rawMessage) return baseResponse; - if (rawMessage.includes('Claude Code process exited')) { + if ( + rawMessage.includes('Claude Code process exited') || + rawMessage.includes('Claude Code process terminated by signal') + ) { + const exitCodeMatch = rawMessage.match(/exited with code (\d+)/); + const signalMatch = rawMessage.match(/terminated by signal (\w+)/); + const detail = exitCodeMatch + ? ` (exit code: ${exitCodeMatch[1]})` + : signalMatch + ? ` (signal: ${signalMatch[1]})` + : ''; + + // Crash/OS-kill signals suggest a process crash, not an auth failure — + // omit auth recovery advice and suggest retry/reporting instead. + const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP']; + const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false; + + if (isCrashSignal) { + return { + statusCode: 503, + userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`, + }; + } + return { statusCode: 503, - userMessage: - 'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.', + userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`, }; } diff --git a/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/apps/server/src/routes/worktree/routes/generate-pr-description.ts index 4e9e5716..a588f82d 100644 --- a/apps/server/src/routes/worktree/routes/generate-pr-description.ts +++ b/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -170,127 +170,125 @@ export function createGeneratePRDescriptionHandler( // Determine the base branch for comparison const base = baseBranch || 'main'; - // Get the diff between current branch and base branch (committed changes) - // Track whether the diff method used only includes committed changes. - // `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes, - // while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already - // include uncommitted working directory changes. - let diff = ''; - let diffIncludesUncommitted = false; + // Collect diffs in three layers and combine them: + // 1. Committed changes on the branch: `git diff base...HEAD` + // 2. Staged (cached) changes not yet committed: `git diff --cached` + // 3. Unstaged changes to tracked files: `git diff` (no --cached flag) + // + // Untracked files are intentionally excluded — they are typically build artifacts, + // planning files, hidden dotfiles, or other files unrelated to the PR. + // `git diff` and `git diff --cached` only show changes to files already tracked by git, + // which is exactly the correct scope. + // + // We combine all three sources and deduplicate by file path so that a file modified + // in commits AND with additional uncommitted changes is not double-counted. + + /** Parse a unified diff into per-file hunks keyed by file path */ + function parseDiffIntoFileHunks(diffText: string): Map { + const fileHunks = new Map(); + if (!diffText.trim()) return fileHunks; + + // Split on "diff --git" boundaries (keep the delimiter) + const sections = diffText.split(/(?=^diff --git )/m); + for (const section of sections) { + if (!section.trim()) continue; + // Use a back-reference pattern so the "b/" side must match the "a/" capture, + // correctly handling paths that contain " b/" in their name. + // Falls back to a two-capture pattern to handle renames (a/ and b/ differ). + const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m); + const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null; + const match = backrefMatch || renameMatch; + if (match) { + // Prefer the backref capture (identical paths); for renames use the destination (match[2]) + const filePath = backrefMatch ? match[1] : match[2]; + // Merge hunks if the same file appears in multiple diff sources + const existing = fileHunks.get(filePath) ?? ''; + fileHunks.set(filePath, existing + section); + } + } + return fileHunks; + } + + // --- Step 1: committed changes (branch vs base) --- + let committedDiff = ''; try { - // First, try to get diff against the base branch - const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], { + const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], { cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, // 5MB buffer + maxBuffer: 1024 * 1024 * 5, }); - diff = branchDiff; - // git diff base...HEAD only shows committed changes - diffIncludesUncommitted = false; + committedDiff = stdout; } catch { - // If branch comparison fails (e.g., base branch doesn't exist locally), - // try fetching and comparing against remote base + // Base branch may not exist locally; try the remote tracking branch try { - const { stdout: remoteDiff } = await execFileAsync( - 'git', - ['diff', `origin/${base}...HEAD`], - { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - } - ); - diff = remoteDiff; - // git diff origin/base...HEAD only shows committed changes - diffIncludesUncommitted = false; + const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + committedDiff = stdout; } catch { - // Fall back to getting all uncommitted + committed changes - try { - const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - }); - diff = allDiff; - // git diff HEAD includes uncommitted changes - diffIncludesUncommitted = true; - } catch { - // Last resort: get staged + unstaged changes - const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - }); - const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - }); - diff = stagedDiff + unstagedDiff; - // These already include uncommitted changes - diffIncludesUncommitted = true; - } + // Cannot compare against base — leave committedDiff empty; the uncommitted + // changes gathered below will still be included. + logger.warn(`Could not get committed diff against ${base} or origin/${base}`); } } - // Check for uncommitted changes (staged + unstaged) to include in the description. - // When creating a PR, uncommitted changes will be auto-committed, so they should be - // reflected in the generated description. We only need to fetch uncommitted diffs - // when the primary diff method (base...HEAD) was used, since it only shows committed changes. - let hasUncommittedChanges = false; + // --- Step 2: staged changes (tracked files only) --- + let stagedDiff = ''; try { - const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], { + const { stdout } = await execFileAsync('git', ['diff', '--cached'], { cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, }); - hasUncommittedChanges = statusOutput.trim().length > 0; - - if (hasUncommittedChanges && !diffIncludesUncommitted) { - logger.info('Uncommitted changes detected, including in PR description context'); - - let uncommittedDiff = ''; - - // Get staged changes - try { - const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - }); - if (stagedDiff.trim()) { - uncommittedDiff += stagedDiff; - } - } catch { - // Ignore staged diff errors - } - - // Get unstaged changes (tracked files only) - try { - const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { - cwd: worktreePath, - maxBuffer: 1024 * 1024 * 5, - }); - if (unstagedDiff.trim()) { - uncommittedDiff += unstagedDiff; - } - } catch { - // Ignore unstaged diff errors - } - - // Get list of untracked files for context - const untrackedFiles = statusOutput - .split('\n') - .filter((line) => line.startsWith('??')) - .map((line) => line.substring(3).trim()); - - if (untrackedFiles.length > 0) { - // Add a summary of untracked (new) files as context - uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`; - } - - // Append uncommitted changes to the committed diff - if (uncommittedDiff.trim()) { - diff = diff + uncommittedDiff; - } - } - } catch { - // Ignore errors checking for uncommitted changes + stagedDiff = stdout; + } catch (err) { + // Non-fatal — staged diff is a best-effort supplement + logger.debug('Failed to get staged diff', err); } - // Also get the commit log for context + // --- Step 3: unstaged changes (tracked files only) --- + let unstagedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + unstagedDiff = stdout; + } catch (err) { + // Non-fatal — unstaged diff is a best-effort supplement + logger.debug('Failed to get unstaged diff', err); + } + + // --- Combine and deduplicate --- + // Build a map of filePath → diff content by concatenating hunks from all sources + // in chronological order (committed → staged → unstaged) so that no changes + // are lost when a file appears in multiple diff sources. + const combinedFileHunks = new Map(); + + for (const source of [committedDiff, stagedDiff, unstagedDiff]) { + const hunks = parseDiffIntoFileHunks(source); + for (const [filePath, hunk] of hunks) { + if (combinedFileHunks.has(filePath)) { + combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk); + } else { + combinedFileHunks.set(filePath, hunk); + } + } + } + + const diff = Array.from(combinedFileHunks.values()).join(''); + + // Log what files were included for observability + if (combinedFileHunks.size > 0) { + logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`); + logger.debug( + `PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}` + ); + } + + // Also get the commit log for context — always scoped to the selected base branch + // so the log only contains commits that are part of this PR. + // We do NOT fall back to an unscoped `git log` because that would include commits + // from the base branch itself and produce misleading AI context. let commitLog = ''; try { const { stdout: logOutput } = await execFileAsync( @@ -303,11 +301,11 @@ export function createGeneratePRDescriptionHandler( ); commitLog = logOutput.trim(); } catch { - // If comparing against base fails, fall back to recent commits + // Base branch not available locally — try the remote tracking branch try { const { stdout: logOutput } = await execFileAsync( 'git', - ['log', '--oneline', '-10', '--no-decorate'], + ['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'], { cwd: worktreePath, maxBuffer: 1024 * 1024, @@ -315,7 +313,9 @@ export function createGeneratePRDescriptionHandler( ); commitLog = logOutput.trim(); } catch { - // Ignore commit log errors + // Cannot scope commit log to base branch — leave empty rather than + // including unscoped commits that would pollute the AI context. + logger.warn(`Could not get commit log against ${base} or origin/${base}`); } } @@ -341,10 +341,6 @@ export function createGeneratePRDescriptionHandler( userPrompt += `\nCommit History:\n${commitLog}\n`; } - if (hasUncommittedChanges) { - userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`; - } - if (truncatedDiff) { userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; } diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 724a5761..ca2a33c4 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -9,6 +9,7 @@ import type { Request, Response } from 'express'; import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import { getErrorMessage, logWorktreeError } from '../common.js'; +import { getRemotesWithBranch } from '../../../services/worktree-service.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -131,6 +132,8 @@ export function createListBranchesHandler() { let behindCount = 0; let hasRemoteBranch = false; let trackingRemote: string | undefined; + // List of remote names that have a branch matching the current branch name + let remotesWithBranch: string[] = []; try { // First check if there's a remote tracking branch const { stdout: upstreamOutput } = await execFileAsync( @@ -172,6 +175,12 @@ export function createListBranchesHandler() { } } + // Check which remotes have a branch matching the current branch name. + // This helps the UI distinguish between "branch exists on tracking remote" vs + // "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin'). + // Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true) + remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes); + res.json({ success: true, result: { @@ -182,6 +191,7 @@ export function createListBranchesHandler() { hasRemoteBranch, hasAnyRemotes, trackingRemote, + remotesWithBranch, }, }); } catch (error) { diff --git a/apps/server/src/services/agent-executor-types.ts b/apps/server/src/services/agent-executor-types.ts index 56a9086c..e84964a1 100644 --- a/apps/server/src/services/agent-executor-types.ts +++ b/apps/server/src/services/agent-executor-types.ts @@ -5,6 +5,7 @@ import type { PlanningMode, ThinkingLevel, + ReasoningEffort, ParsedTask, ClaudeCompatibleProvider, Credentials, @@ -24,7 +25,9 @@ export interface AgentExecutionOptions { previousContent?: string; systemPrompt?: string; autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; branchName?: string | null; credentials?: Credentials; claudeCompatibleProvider?: ClaudeCompatibleProvider; diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 1ef7bed9..a478450a 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -128,6 +128,7 @@ export class AgentExecutor { ? (mcpServers as Record) : undefined, thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, credentials, claudeCompatibleProvider, sdkSessionId, @@ -703,6 +704,7 @@ export class AgentExecutor { allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, abortController: o.abortController, thinkingLevel: o.thinkingLevel, + reasoningEffort: o.reasoningEffort, mcpServers: o.mcpServers && Object.keys(o.mcpServers).length > 0 ? (o.mcpServers as Record) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index eb2f3d9c..d62b3442 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -21,6 +21,7 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options. import type { SettingsService } from './settings-service.js'; import { getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, getMCPServersFromSettings, getPromptCustomization, @@ -357,6 +358,22 @@ export class AgentService { '[AgentService]' ); + // Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global) + // Wrap in try/catch so transient settingsService errors don't abort message processing + let useClaudeCodeSystemPrompt = true; + try { + useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + effectiveWorkDir, + this.settingsService, + '[AgentService]' + ); + } catch (err) { + this.logger.error( + '[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true', + err + ); + } + // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); @@ -443,6 +460,7 @@ export class AgentService { systemPrompt: combinedSystemPrompt, abortController: session.abortController!, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models maxTurns: userMaxTurns, // User-configured max turns from settings mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 150be157..241333de 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -14,7 +14,7 @@ import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; -import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types'; import { resolveModelString } from '@automaker/model-resolver'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; @@ -213,7 +213,9 @@ export class AutoModeServiceFacade { previousContent?: string; systemPrompt?: string; autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; branchName?: string | null; [key: string]: unknown; } @@ -244,6 +246,7 @@ export class AutoModeServiceFacade { // internal defaults which may be much lower than intended (e.g., Codex CLI's // default turn limit can cause feature runs to stop prematurely). const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false; + const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true; let mcpServers: Record | undefined; try { if (settingsService) { @@ -265,6 +268,7 @@ export class AutoModeServiceFacade { systemPrompt: opts?.systemPrompt, abortController, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: opts?.thinkingLevel, maxTurns: userMaxTurns, mcpServers: mcpServers as @@ -292,7 +296,9 @@ export class AutoModeServiceFacade { previousContent: opts?.previousContent as string | undefined, systemPrompt: opts?.systemPrompt as string | undefined, autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, + useClaudeCodeSystemPrompt, thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, + reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined, branchName: opts?.branchName as string | null | undefined, provider, effectiveBareModel, diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index ff14a993..ddef051d 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -64,6 +64,8 @@ interface AutoModeEventPayload { error?: string; errorType?: string; projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; } /** @@ -75,6 +77,28 @@ interface FeatureCreatedPayload { projectPath: string; } +/** + * Feature status changed event payload structure + */ +interface FeatureStatusChangedPayload { + featureId: string; + projectPath: string; + status: string; +} + +/** + * Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload + */ +function isFeatureStatusChangedPayload( + payload: AutoModeEventPayload +): payload is AutoModeEventPayload & FeatureStatusChangedPayload { + return ( + typeof payload.featureId === 'string' && + typeof payload.projectPath === 'string' && + typeof payload.status === 'string' + ); +} + /** * Event Hook Service * @@ -82,12 +106,30 @@ interface FeatureCreatedPayload { * Also stores events to history for debugging and replay. */ export class EventHookService { + /** Feature status that indicates agent work is done and awaiting human review (tests skipped) */ + private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval'; + /** Feature status that indicates agent work passed automated verification */ + private static readonly STATUS_VERIFIED = 'verified'; + private emitter: EventEmitter | null = null; private settingsService: SettingsService | null = null; private eventHistoryService: EventHistoryService | null = null; private featureLoader: FeatureLoader | null = null; private unsubscribe: (() => void) | null = null; + /** + * Track feature IDs that have already had hooks fired via auto_mode_feature_complete + * to prevent double-firing when feature_status_changed also fires for the same feature. + * Entries are automatically cleaned up after 30 seconds. + */ + private recentlyHandledFeatures = new Set(); + + /** + * Timer IDs for pending cleanup of recentlyHandledFeatures entries, + * keyed by featureId. Stored so they can be cancelled in destroy(). + */ + private recentlyHandledTimers = new Map>(); + /** * Initialize the service with event emitter, settings service, event history service, and feature loader */ @@ -122,6 +164,12 @@ export class EventHookService { this.unsubscribe(); this.unsubscribe = null; } + // Cancel all pending cleanup timers to avoid cross-session mutations + for (const timerId of this.recentlyHandledTimers.values()) { + clearTimeout(timerId); + } + this.recentlyHandledTimers.clear(); + this.recentlyHandledFeatures.clear(); this.emitter = null; this.settingsService = null; this.eventHistoryService = null; @@ -140,14 +188,27 @@ export class EventHookService { switch (payload.type) { case 'auto_mode_feature_complete': trigger = payload.passes ? 'feature_success' : 'feature_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } break; case 'auto_mode_error': // Feature-level error (has featureId) vs auto-mode level error trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } break; case 'auto_mode_idle': trigger = 'auto_mode_complete'; break; + case 'feature_status_changed': + if (isFeatureStatusChangedPayload(payload)) { + this.handleFeatureStatusChanged(payload); + } + return; default: // Other event types don't trigger hooks return; @@ -203,6 +264,74 @@ export class EventHookService { await this.executeHooksForTrigger('feature_created', context); } + /** + * Handle feature_status_changed events for non-auto-mode feature completion. + * + * Auto-mode features already emit auto_mode_feature_complete which triggers hooks. + * This handler catches manual (non-auto-mode) feature completions by detecting + * status transitions to completion states (verified, waiting_approval). + */ + private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise { + // Skip if this feature was already handled via auto_mode_feature_complete + if (this.recentlyHandledFeatures.has(payload.featureId)) { + return; + } + + let trigger: EventHookTrigger | null = null; + + if ( + payload.status === EventHookService.STATUS_VERIFIED || + payload.status === EventHookService.STATUS_WAITING_APPROVAL + ) { + trigger = 'feature_success'; + } else { + // Only completion statuses trigger hooks from status changes + return; + } + + // Load feature name + let featureName: string | undefined = undefined; + if (this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error); + } + } + + const context: HookContext = { + featureId: payload.featureId, + featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + await this.executeHooksForTrigger(trigger, context, { passes: true }); + } + + /** + * Mark a feature as recently handled to prevent double-firing hooks. + * Entries are cleaned up after 30 seconds. + */ + private markFeatureHandled(featureId: string): void { + // Cancel any existing timer for this feature before setting a new one + const existing = this.recentlyHandledTimers.get(featureId); + if (existing !== undefined) { + clearTimeout(existing); + } + this.recentlyHandledFeatures.add(featureId); + const timerId = setTimeout(() => { + this.recentlyHandledFeatures.delete(featureId); + this.recentlyHandledTimers.delete(featureId); + }, 30000); + this.recentlyHandledTimers.set(featureId, timerId); + } + /** * Execute all enabled hooks matching the given trigger and store event to history */ diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index c98e9d89..e5fb4028 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js'; import { getPromptCustomization, getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, } from '../lib/settings-helpers.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js'; @@ -241,6 +242,11 @@ ${feature.spec} this.settingsService, '[ExecutionService]' ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[ExecutionService]' + ); const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); let prompt: string; const contextResult = await this.loadContextFilesFn({ @@ -289,7 +295,9 @@ ${feature.spec} requirePlanApproval: feature.requirePlanApproval, systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, branchName: feature.branchName ?? null, } ); @@ -353,7 +361,9 @@ Please continue from where you left off and complete all remaining tasks. Use th requirePlanApproval: false, systemPrompt: combinedSystemPrompt || undefined, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, branchName: feature.branchName ?? null, } ); @@ -388,6 +398,7 @@ Please continue from where you left off and complete all remaining tasks. Use th branchName: feature.branchName ?? null, abortController, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, testAttempts: 0, maxTestAttempts: 5, }); diff --git a/apps/server/src/services/execution-types.ts b/apps/server/src/services/execution-types.ts index 6cb9cb5f..8a98b243 100644 --- a/apps/server/src/services/execution-types.ts +++ b/apps/server/src/services/execution-types.ts @@ -5,7 +5,7 @@ * allowing the service to delegate to other services without circular dependencies. */ -import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types'; +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; import type { loadContextFiles } from '@automaker/utils'; import type { PipelineContext } from './pipeline-orchestrator.js'; @@ -31,7 +31,9 @@ export type RunAgentFn = ( previousContent?: string; systemPrompt?: string; autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; branchName?: string | null; } ) => Promise; diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index 2e79b24b..719d9261 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -16,6 +16,7 @@ import * as secureFs from '../lib/secure-fs.js'; import { getPromptCustomization, getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, } from '../lib/settings-helpers.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js'; @@ -70,8 +71,16 @@ export class PipelineOrchestrator { ) {} async executePipeline(ctx: PipelineContext): Promise { - const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } = - ctx; + const { + projectPath, + featureId, + feature, + steps, + workDir, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + } = ctx; const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const contextResult = await this.loadContextFilesFn({ projectPath, @@ -121,7 +130,9 @@ export class PipelineOrchestrator { previousContent: previousContext, systemPrompt: contextFilesPrompt || undefined, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, } ); try { @@ -354,6 +365,11 @@ export class PipelineOrchestrator { this.settingsService, '[AutoMode]' ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); const context: PipelineContext = { projectPath, featureId, @@ -364,6 +380,7 @@ export class PipelineOrchestrator { branchName: branchName ?? null, abortController, autoLoadClaudeMd, + useClaudeCodeSystemPrompt, testAttempts: 0, maxTestAttempts: 5, }; @@ -462,7 +479,14 @@ export class PipelineOrchestrator { projectPath, undefined, undefined, - { projectPath, planningMode: 'skip', requirePlanApproval: false } + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt, + autoLoadClaudeMd: context.autoLoadClaudeMd, + reasoningEffort: context.feature.reasoningEffort, + } ); } } @@ -596,7 +620,7 @@ export class PipelineOrchestrator { } // Only capture assertion details when they appear in failure context // or match explicit assertion error / expect patterns - if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) { + if (trimmed.includes('AssertionError')) { failedTests.push(trimmed); } else if ( inFailureContext && diff --git a/apps/server/src/services/pipeline-types.ts b/apps/server/src/services/pipeline-types.ts index be41f331..67957b6a 100644 --- a/apps/server/src/services/pipeline-types.ts +++ b/apps/server/src/services/pipeline-types.ts @@ -14,6 +14,7 @@ export interface PipelineContext { branchName: string | null; abortController: AbortController; autoLoadClaudeMd: boolean; + useClaudeCodeSystemPrompt?: boolean; testAttempts: number; maxTestAttempts: number; } diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index ebf8556f..7b3ffa70 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -31,6 +31,7 @@ import type { WorktreeInfo, PhaseModelConfig, PhaseModelEntry, + FeatureTemplate, ClaudeApiProfile, ClaudeCompatibleProvider, ProviderModel, @@ -40,6 +41,7 @@ import { DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, @@ -139,6 +141,11 @@ export class SettingsService { // Migrate model IDs to canonical format const migratedModelSettings = this.migrateModelSettings(settings); + // Merge built-in feature templates: ensure all built-in templates exist in user settings. + // User customizations (enabled/disabled state, order overrides) are preserved. + // New built-in templates added in code updates are injected for existing users. + const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates); + // Apply any missing defaults (for backwards compatibility) let result: GlobalSettings = { ...DEFAULT_GLOBAL_SETTINGS, @@ -149,6 +156,7 @@ export class SettingsService { ...settings.keyboardShortcuts, }, phaseModels: migratedPhaseModels, + featureTemplates: mergedFeatureTemplates, }; // Version-based migrations @@ -250,6 +258,32 @@ export class SettingsService { return result; } + /** + * Merge built-in feature templates with user's stored templates. + * + * Ensures new built-in templates added in code updates are available to existing users + * without overwriting their customizations (e.g., enabled/disabled state, custom order). + * Built-in templates missing from stored settings are appended with their defaults. + * + * @param storedTemplates - Templates from user's settings file (may be undefined for new installs) + * @returns Merged template list with all built-in templates present + */ + private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] { + if (!storedTemplates) { + return DEFAULT_FEATURE_TEMPLATES; + } + + const storedIds = new Set(storedTemplates.map((t) => t.id)); + const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id)); + + if (missingBuiltIns.length === 0) { + return storedTemplates; + } + + // Append missing built-in templates after existing ones + return [...storedTemplates, ...missingBuiltIns]; + } + /** * Migrate legacy enhancementModel/validationModel fields to phaseModels structure * diff --git a/apps/server/src/services/worktree-service.ts b/apps/server/src/services/worktree-service.ts index 8490c108..57ce5b40 100644 --- a/apps/server/src/services/worktree-service.ts +++ b/apps/server/src/services/worktree-service.ts @@ -8,9 +8,64 @@ import path from 'path'; import fs from 'fs/promises'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import type { EventEmitter } from '../lib/events.js'; import type { SettingsService } from './settings-service.js'; +const execFileAsync = promisify(execFile); + +/** + * Get the list of remote names that have a branch matching the given branch name. + * + * Uses `git for-each-ref` to check cached remote refs, returning the names of + * any remotes that already have a branch with the same name as `currentBranch`. + * Returns an empty array when `hasAnyRemotes` is false or when no matching + * remote refs are found. + * + * This helps the UI distinguish between "branch exists on the tracking remote" + * vs "branch was pushed to a different remote". + * + * @param worktreePath - Path to the git worktree + * @param currentBranch - Branch name to search for on remotes + * @param hasAnyRemotes - Whether the repository has any remotes configured + * @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch + */ +export async function getRemotesWithBranch( + worktreePath: string, + currentBranch: string, + hasAnyRemotes: boolean +): Promise { + if (!hasAnyRemotes) { + return []; + } + + try { + const { stdout: remoteRefsOutput } = await execFileAsync( + 'git', + ['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`], + { cwd: worktreePath } + ); + + if (!remoteRefsOutput.trim()) { + return []; + } + + return remoteRefsOutput + .trim() + .split('\n') + .map((ref) => { + // Extract remote name from "remote/branch" format + const slashIdx = ref.indexOf('/'); + return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref; + }) + .filter((name) => name.length > 0); + } catch { + // Ignore errors - return empty array + return []; + } +} + /** * Error thrown when one or more file copy operations fail during * `copyConfiguredFiles`. The caller can inspect `failures` for details. diff --git a/apps/server/src/types/settings.ts b/apps/server/src/types/settings.ts index 6863b314..30ce1722 100644 --- a/apps/server/src/types/settings.ts +++ b/apps/server/src/types/settings.ts @@ -23,6 +23,7 @@ export type { PhaseModelConfig, PhaseModelKey, PhaseModelEntry, + FeatureTemplate, // Claude-compatible provider types ApiKeySource, ClaudeCompatibleProviderType, @@ -41,6 +42,7 @@ export { DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, diff --git a/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts b/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts new file mode 100644 index 00000000..92556a35 --- /dev/null +++ b/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BacklogPlanResult, ProviderMessage } from '@automaker/types'; + +const { + mockGetAll, + mockExecuteQuery, + mockSaveBacklogPlan, + mockSetRunningState, + mockSetRunningDetails, + mockGetPromptCustomization, + mockGetAutoLoadClaudeMdSetting, + mockGetUseClaudeCodeSystemPromptSetting, +} = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockExecuteQuery: vi.fn(), + mockSaveBacklogPlan: vi.fn(), + mockSetRunningState: vi.fn(), + mockSetRunningDetails: vi.fn(), + mockGetPromptCustomization: vi.fn(), + mockGetAutoLoadClaudeMdSetting: vi.fn(), + mockGetUseClaudeCodeSystemPromptSetting: vi.fn(), +})); + +vi.mock('@/services/feature-loader.js', () => ({ + FeatureLoader: class { + getAll = mockGetAll; + }, +})); + +vi.mock('@/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderForModel: vi.fn(() => ({ + executeQuery: mockExecuteQuery, + })), + }, +})); + +vi.mock('@/routes/backlog-plan/common.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + setRunningState: mockSetRunningState, + setRunningDetails: mockSetRunningDetails, + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + saveBacklogPlan: mockSaveBacklogPlan, +})); + +vi.mock('@/lib/settings-helpers.js', () => ({ + getPromptCustomization: mockGetPromptCustomization, + getAutoLoadClaudeMdSetting: mockGetAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting: mockGetUseClaudeCodeSystemPromptSetting, + getPhaseModelWithOverrides: vi.fn(), +})); + +import { generateBacklogPlan } from '@/routes/backlog-plan/generate-plan.js'; + +function createMockEvents() { + return { + emit: vi.fn(), + }; +} + +describe('generateBacklogPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockGetAll.mockResolvedValue([]); + mockGetPromptCustomization.mockResolvedValue({ + backlogPlan: { + systemPrompt: 'System instructions', + userPromptTemplate: + 'Current features:\n{{currentFeatures}}\n\nUser request:\n{{userRequest}}', + }, + }); + mockGetAutoLoadClaudeMdSetting.mockResolvedValue(false); + mockGetUseClaudeCodeSystemPromptSetting.mockResolvedValue(true); + }); + + it('salvages valid streamed JSON when Claude process exits with code 1', async () => { + const partialResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add signup form', + description: 'Create signup UI and validation', + category: 'frontend', + }, + reason: 'Required for user onboarding', + }, + ], + summary: 'Adds signup feature to the backlog', + dependencyUpdates: [], + }; + + const responseJson = JSON.stringify(partialResult); + + async function* streamWithExitError(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: responseJson }], + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithExitError()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Please add a signup feature', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual(partialResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + prompt: 'Please add a signup feature', + model: 'claude-opus-4-6', + result: partialResult, + }) + ); + expect(events.emit).toHaveBeenCalledWith('backlog-plan:event', { + type: 'backlog_plan_complete', + result: partialResult, + }); + expect(mockSetRunningState).toHaveBeenCalledWith(false, null); + expect(mockSetRunningDetails).toHaveBeenCalledWith(null); + }); + + it('prefers parseable provider result over longer non-JSON accumulated text on exit', async () => { + const recoveredResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add reset password flow', + description: 'Implement reset password request and token validation UI', + category: 'frontend', + }, + reason: 'Supports account recovery', + }, + ], + summary: 'Adds password reset capability', + dependencyUpdates: [], + }; + + const validProviderResult = JSON.stringify(recoveredResult); + const invalidAccumulatedText = `${validProviderResult}\n\nAdditional commentary that breaks raw JSON parsing.`; + + async function* streamWithResultThenExit(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: invalidAccumulatedText }], + }, + }; + yield { + type: 'result', + subtype: 'success', + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: validProviderResult, + session_id: 'session-1', + total_cost_usd: 0, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 10, + server_tool_use: { + web_search_requests: 0, + }, + service_tier: 'standard', + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithResultThenExit()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Add password reset support', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(result).toEqual(recoveredResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + result: recoveredResult, + }) + ); + }); +}); diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts index 1448fb80..2ef9b246 100644 --- a/apps/server/tests/unit/services/event-hook-service.test.ts +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -524,6 +524,202 @@ describe('EventHookService', () => { }); }); + describe('event mapping - feature_status_changed (non-auto-mode completion)', () => { + it('should trigger feature_success when status changes to verified', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.featureName).toBe('Manual Feature'); + expect(storeCall.passes).toBe(true); + }); + + it('should trigger feature_success when status changes to waiting_approval', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'waiting_approval', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + expect(storeCall.featureName).toBe('Manual Feature'); + }); + + it('should NOT trigger hooks for non-completion status changes', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'in_progress', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + + it('should NOT double-fire hooks when auto_mode_feature_complete already fired', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_feature_complete fires (auto-mode path) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + featureId: 'feat-1', + featureName: 'Auto Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once (from auto_mode_feature_complete) + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should NOT double-fire hooks when auto_mode_error already fired for feature', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_error fires for a feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Something failed', + errorType: 'execution', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature (e.g., reset to backlog) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', // unlikely after error, but tests the dedup + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should fire hooks for different features independently', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Auto-mode completion for feat-1 + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + featureId: 'feat-1', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Manual completion for feat-2 (different feature) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-2', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(2); + }); + + // feat-2 should have triggered feature_success + const secondCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[1][0]; + expect(secondCall.trigger).toBe('feature_success'); + expect(secondCall.featureId).toBe('feat-2'); + }); + }); + describe('error context for error events', () => { it('should use payload.error when available for error triggers', async () => { service.initialize( diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index f56e5c41..a5d001f8 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -34,6 +34,7 @@ import { getFeatureDir } from '@automaker/platform'; import { getPromptCustomization, getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, filterClaudeMdFromContext, } from '../../../src/lib/settings-helpers.js'; import { extractSummary } from '../../../src/services/spec-parser.js'; @@ -67,6 +68,7 @@ vi.mock('../../../src/lib/settings-helpers.js', () => ({ }, }), getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), })); @@ -230,6 +232,7 @@ describe('execution-service.ts', () => { }, } as Awaited>); vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(getUseClaudeCodeSystemPromptSetting).mockResolvedValue(true); vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); // Re-setup spec-parser mock diff --git a/apps/server/tests/unit/services/pipeline-orchestrator.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts index 079db0e7..d4f34265 100644 --- a/apps/server/tests/unit/services/pipeline-orchestrator.test.ts +++ b/apps/server/tests/unit/services/pipeline-orchestrator.test.ts @@ -57,6 +57,7 @@ vi.mock('../../../src/lib/settings-helpers.js', () => ({ }, }), getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), })); diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index 516599d6..503aa9c3 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -40,14 +40,12 @@ export function ProjectSwitcher() { const location = useLocation(); const { hideWiki } = SIDEBAR_FEATURE_FLAGS; const isWikiActive = location.pathname === '/wiki'; - const { - projects, - currentProject, - setCurrentProject, - upsertAndSetCurrentProject, - specCreatingForProject, - setSpecCreatingForProject, - } = useAppStore(); + const projects = useAppStore((s) => s.projects); + const currentProject = useAppStore((s) => s.currentProject); + const setCurrentProject = useAppStore((s) => s.setCurrentProject); + const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject); + const specCreatingForProject = useAppStore((s) => s.specCreatingForProject); + const setSpecCreatingForProject = useAppStore((s) => s.setSpecCreatingForProject); const [contextMenuProject, setContextMenuProject] = useState(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( null @@ -104,6 +102,10 @@ export function ProjectSwitcher() { const handleProjectClick = useCallback( async (project: Project) => { + if (project.id === currentProject?.id) { + navigate({ to: '/board' }); + return; + } try { // Ensure .automaker directory structure exists before switching await initializeProject(project.path); @@ -124,7 +126,7 @@ export function ProjectSwitcher() { navigate({ to: '/board' }); }); }, - [setCurrentProject, navigate] + [currentProject?.id, setCurrentProject, navigate] ); const handleNewProject = () => { diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx index 34a98fa7..2c62c634 100644 --- a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, startTransition } from 'react'; import { Folder, ChevronDown, @@ -78,21 +78,22 @@ export function ProjectSelectorWithOptions({ setShowDeleteProjectDialog, setShowRemoveFromAutomakerDialog, }: ProjectSelectorWithOptionsProps) { - const { - projects, - currentProject, - projectHistory, - setCurrentProject, - reorderProjects, - cyclePrevProject, - cycleNextProject, - clearProjectHistory, - } = useAppStore(); + const projects = useAppStore((s) => s.projects); + const currentProject = useAppStore((s) => s.currentProject); + const projectHistory = useAppStore((s) => s.projectHistory); + const setCurrentProject = useAppStore((s) => s.setCurrentProject); + const reorderProjects = useAppStore((s) => s.reorderProjects); + const cyclePrevProject = useAppStore((s) => s.cyclePrevProject); + const cycleNextProject = useAppStore((s) => s.cycleNextProject); + const clearProjectHistory = useAppStore((s) => s.clearProjectHistory); const shortcuts = useKeyboardShortcutsConfig(); // Wrap setCurrentProject to ensure .automaker is initialized before switching const setCurrentProjectWithInit = useCallback( async (p: Project) => { + if (p.id === currentProject?.id) { + return; + } try { // Ensure .automaker directory structure exists before switching await initializeProject(p.path); @@ -101,9 +102,12 @@ export function ProjectSelectorWithOptions({ // Continue with switch even if initialization fails - // the project may already be initialized } - setCurrentProject(p); + // Defer project switch update to avoid synchronous render cascades. + startTransition(() => { + setCurrentProject(p); + }); }, - [setCurrentProject] + [currentProject?.id, setCurrentProject] ); const { diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx index f345f376..514265f6 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-header.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, startTransition } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { ChevronsUpDown, Folder, Plus, FolderOpen, LogOut } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; @@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react'; import { cn, isMac } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import { isElectron, type Project } from '@/lib/electron'; +import { initializeProject } from '@/lib/project-init'; import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useAppStore } from '@/store/app-store'; @@ -36,7 +37,8 @@ export function SidebarHeader({ setShowRemoveFromAutomakerDialog, }: SidebarHeaderProps) { const navigate = useNavigate(); - const { projects, setCurrentProject } = useAppStore(); + const projects = useAppStore((s) => s.projects); + const setCurrentProject = useAppStore((s) => s.setCurrentProject); const [dropdownOpen, setDropdownOpen] = useState(false); const handleLogoClick = useCallback(() => { @@ -44,12 +46,29 @@ export function SidebarHeader({ }, [navigate]); const handleProjectSelect = useCallback( - (project: Project) => { - setCurrentProject(project); - setDropdownOpen(false); - navigate({ to: '/board' }); + async (project: Project) => { + if (project.id === currentProject?.id) { + setDropdownOpen(false); + navigate({ to: '/board' }); + return; + } + try { + // Ensure .automaker directory structure exists before switching + await initializeProject(project.path); + } catch (error) { + console.error('Failed to initialize project during switch:', error); + // Continue with switch even if initialization fails - + // the project may already be initialized + } + + // Batch project switch + navigation to prevent multi-render cascades. + startTransition(() => { + setCurrentProject(project); + setDropdownOpen(false); + navigate({ to: '/board' }); + }); }, - [setCurrentProject, navigate] + [currentProject?.id, setCurrentProject, navigate] ); const getIconComponent = (project: Project): LucideIcon => { diff --git a/apps/ui/src/components/layout/sidebar/sidebar.tsx b/apps/ui/src/components/layout/sidebar/sidebar.tsx index 9cb6cd86..ae9f03de 100644 --- a/apps/ui/src/components/layout/sidebar/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar/sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, startTransition } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { useNavigate, useLocation } from '@tanstack/react-router'; import { PanelLeftClose, ChevronDown } from 'lucide-react'; @@ -281,6 +281,27 @@ export function Sidebar() { // Register keyboard shortcuts useKeyboardShortcuts(navigationShortcuts); + const switchProjectSafely = useCallback( + async (targetProject: Project) => { + // Ensure .automaker directory structure exists before switching + const initResult = await initializeProject(targetProject.path); + if (!initResult.success) { + logger.error('Failed to initialize project during switch:', initResult.error); + toast.warning( + `Could not fully initialize project: ${initResult.error ?? 'Unknown error'}. Some features may not work correctly.` + ); + // Continue with switch despite init failure — project may already be partially initialized + } + + // Batch project switch + navigation to prevent multi-render cascades. + startTransition(() => { + setCurrentProject(targetProject); + navigate({ to: '/board' }); + }); + }, + [setCurrentProject, navigate] + ); + // Keyboard shortcuts for project switching (1-9, 0) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -305,15 +326,14 @@ export function Sidebar() { if (projectIndex !== null && projectIndex < projects.length) { const targetProject = projects[projectIndex]; if (targetProject && targetProject.id !== currentProject?.id) { - setCurrentProject(targetProject); - navigate({ to: '/board' }); + void switchProjectSafely(targetProject); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [projects, currentProject, setCurrentProject, navigate]); + }, [projects, currentProject, switchProjectSafely]); const isActiveRoute = (id: string) => { const routePath = id === 'welcome' ? '/' : `/${id}`; diff --git a/apps/ui/src/components/shared/use-model-override.ts b/apps/ui/src/components/shared/use-model-override.ts index bd0027f1..4d591752 100644 --- a/apps/ui/src/components/shared/use-model-override.ts +++ b/apps/ui/src/components/shared/use-model-override.ts @@ -29,8 +29,13 @@ export interface UseModelOverrideResult { /** * Normalize PhaseModelEntry or string to PhaseModelEntry + * Handles undefined/null gracefully (e.g., when phaseModels from server settings + * is missing a recently-added phase key) */ -function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry { +function normalizeEntry(entry: PhaseModelEntry | string | undefined | null): PhaseModelEntry { + if (!entry) { + return { model: 'claude-sonnet' as ModelId }; + } if (typeof entry === 'string') { return { model: entry as ModelId }; } diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 5f2f919a..213f778d 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -27,10 +27,14 @@ class DialogAwarePointerSensor extends PointerSensor { }, ]; } -import { useAppStore, Feature } from '@/store/app-store'; +import { useAppStore, Feature, type ModelAlias, type ThinkingLevel } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; -import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types'; +import type { + BacklogPlanResult, + FeatureStatusWithPipeline, + FeatureTemplate, +} from '@automaker/types'; import { pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { @@ -58,6 +62,7 @@ import { FollowUpDialog, PlanApprovalDialog, MergeRebaseDialog, + QuickAddDialog, } from './board-view/dialogs'; import type { DependencyLinkType } from './board-view/dialogs'; import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; @@ -75,6 +80,7 @@ import type { StashPopConflictInfo, StashApplyConflictInfo, } from './board-view/worktree-panel/types'; +import { BoardErrorBoundary } from './board-view/board-error-boundary'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { useBoardFeatures, @@ -124,6 +130,7 @@ export function BoardView() { isPrimaryWorktreeBranch, getPrimaryWorktreeBranch, setPipelineConfig, + featureTemplates, } = useAppStore( useShallow((state) => ({ currentProject: state.currentProject, @@ -142,8 +149,11 @@ export function BoardView() { isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch, getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch, setPipelineConfig: state.setPipelineConfig, + featureTemplates: state.featureTemplates, })) ); + // Also get keyboard shortcuts for the add feature shortcut + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); // Fetch pipeline config via React Query const { data: pipelineConfig } = usePipelineConfig(currentProject?.path); const queryClient = useQueryClient(); @@ -165,6 +175,7 @@ export function BoardView() { } = useBoardFeatures({ currentProject }); const [editingFeature, setEditingFeature] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); + const [showQuickAddDialog, setShowQuickAddDialog] = useState(false); const [isMounted, setIsMounted] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false); const [outputFeature, setOutputFeature] = useState(null); @@ -418,7 +429,7 @@ export function BoardView() { (branchName: string) => { const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id); if (affectedIds.length === 0) return; - const updates: Partial = { branchName: null }; + const updates: Partial = { branchName: undefined }; batchUpdateFeatures(affectedIds, updates); for (const id of affectedIds) { persistFeatureUpdate(id, updates).catch((err: unknown) => { @@ -642,6 +653,15 @@ export function BoardView() { ); }, [hookFeatures, worktrees]); + // Recovery handler for BoardErrorBoundary: reset worktree selection to main + // so the board can re-render without the stale worktree state that caused the crash. + const handleBoardRecover = useCallback(() => { + if (!currentProject) return; + const mainWorktree = worktrees.find((w) => w.isMain); + const mainBranch = mainWorktree?.branch || 'main'; + setCurrentWorktree(currentProject.path, null, mainBranch); + }, [currentProject, worktrees, setCurrentWorktree]); + // Helper function to add and select a worktree const addAndSelectWorktree = useCallback( (worktreeResult: { path: string; branch: string }) => { @@ -992,6 +1012,87 @@ export function BoardView() { [handleAddFeature, handleStartImplementation] ); + // Handler for Quick Add - creates a feature with minimal data using defaults + const handleQuickAdd = useCallback( + async ( + description: string, + modelEntry: { model: string; thinkingLevel?: string; reasoningEffort?: string } + ) => { + // Generate a title from the first line of the description + const title = description.split('\n')[0].substring(0, 100); + + await handleAddFeature({ + title, + description, + category: '', + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: resolveModelString(modelEntry.model) as ModelAlias, + thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none', + branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '', + priority: 2, + planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip', + requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false, + dependencies: [], + workMode: addFeatureUseSelectedWorktreeBranch ? 'custom' : 'current', + }); + }, + [ + handleAddFeature, + defaultSkipTests, + addFeatureUseSelectedWorktreeBranch, + selectedWorktreeBranch, + ] + ); + + // Handler for Quick Add & Start - creates and immediately starts a feature + const handleQuickAddAndStart = useCallback( + async ( + description: string, + modelEntry: { model: string; thinkingLevel?: string; reasoningEffort?: string } + ) => { + // Generate a title from the first line of the description + const title = description.split('\n')[0].substring(0, 100); + + await handleAddAndStartFeature({ + title, + description, + category: '', + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: resolveModelString(modelEntry.model) as ModelAlias, + thinkingLevel: (modelEntry.thinkingLevel as ThinkingLevel) || 'none', + branchName: addFeatureUseSelectedWorktreeBranch ? selectedWorktreeBranch : '', + priority: 2, + planningMode: useAppStore.getState().defaultPlanningMode ?? 'skip', + requirePlanApproval: useAppStore.getState().defaultRequirePlanApproval ?? false, + dependencies: [], + workMode: addFeatureUseSelectedWorktreeBranch ? 'custom' : 'current', + initialStatus: 'in_progress', + }); + }, + [ + handleAddAndStartFeature, + defaultSkipTests, + addFeatureUseSelectedWorktreeBranch, + selectedWorktreeBranch, + ] + ); + + // Handler for template selection - creates a feature from a template + const handleTemplateSelect = useCallback( + async (template: FeatureTemplate) => { + const modelEntry = template.model || + useAppStore.getState().defaultFeatureModel || { model: 'claude-opus' }; + + // Start the template immediately (same behavior as clicking "Make") + await handleQuickAddAndStart(template.prompt, modelEntry); + }, + [handleQuickAddAndStart] + ); + // Handler for managing PR comments - opens the PR Comment Resolution dialog const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => { setPRCommentDialogPRInfo({ @@ -1561,147 +1662,159 @@ export function BoardView() { onViewModeChange={setViewMode} /> - {/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */} - - {/* Worktree Panel - conditionally rendered based on visibility setting */} - {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( - setShowCreateWorktreeDialog(true)} - onDeleteWorktree={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowDeleteWorktreeDialog(true); - }} - onCommit={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCommitWorktreeDialog(true); - }} - onCreatePR={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreatePRDialog(true); - }} - onCreateBranch={(worktree) => { - setSelectedWorktreeForAction(worktree); - setShowCreateBranchDialog(true); - }} - onAddressPRComments={handleAddressPRComments} - onAutoAddressPRComments={handleAutoAddressPRComments} - onResolveConflicts={handleResolveConflicts} - onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} - onBranchSwitchConflict={handleBranchSwitchConflict} - onStashPopConflict={handleStashPopConflict} - onStashApplyConflict={handleStashApplyConflict} - onBranchDeletedDuringMerge={(branchName) => { - batchResetBranchFeatures(branchName); - setWorktreeRefreshKey((k) => k + 1); - }} - onRemovedWorktrees={handleRemovedWorktrees} - runningFeatureIds={runningAutoTasksAllWorktrees} - branchCardCounts={branchCardCounts} - features={hookFeatures.map((f) => ({ - id: f.id, - branchName: f.branchName, - }))} - /> - )} - - {/* Main Content Area */} -
- {/* View Content - Kanban Board or List View */} - {isListView ? ( - setEditingFeature(feature), - onDelete: (featureId) => handleDeleteFeature(featureId), - onViewOutput: handleViewOutput, - onVerify: handleVerifyFeature, - onResume: handleResumeFeature, - onForceStop: handleForceStopFeature, - onManualVerify: handleManualVerify, - onFollowUp: handleOpenFollowUp, - onImplement: handleStartImplementation, - onComplete: handleCompleteFeature, - onViewPlan: (feature) => setViewPlanFeature(feature), - onApprovePlan: handleOpenApprovalDialog, - onSpawnTask: (feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); - }, - onDuplicate: (feature) => handleDuplicateFeature(feature, false), - onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), - onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature), + {/* BoardErrorBoundary catches render errors during worktree switches (e.g. React + error #185 re-render cascades on mobile Safari PWA) and provides a recovery UI + that resets to main branch instead of crashing the entire page. */} + + {/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */} + + {/* Worktree Panel - conditionally rendered based on visibility setting */} + {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( + setShowCreateWorktreeDialog(true)} + onDeleteWorktree={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowDeleteWorktreeDialog(true); }} - runningAutoTasks={runningAutoTasksAllWorktrees} - pipelineConfig={pipelineConfig} - onAddFeature={() => setShowAddDialog(true)} - isSelectionMode={isSelectionMode} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onRowClick={(feature) => { - if (feature.status === 'backlog') { - setEditingFeature(feature); - } else { - handleViewOutput(feature); - } + onCommit={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCommitWorktreeDialog(true); }} - className="transition-opacity duration-200" - /> - ) : ( - setEditingFeature(feature)} - onDelete={(featureId) => handleDeleteFeature(featureId)} - onViewOutput={handleViewOutput} - onVerify={handleVerifyFeature} - onResume={handleResumeFeature} - onForceStop={handleForceStopFeature} - onManualVerify={handleManualVerify} - onMoveBackToInProgress={handleMoveBackToInProgress} - onFollowUp={handleOpenFollowUp} - onComplete={handleCompleteFeature} - onImplement={handleStartImplementation} - onViewPlan={(feature) => setViewPlanFeature(feature)} - onApprovePlan={handleOpenApprovalDialog} - onSpawnTask={(feature) => { - setSpawnParentFeature(feature); - setShowAddDialog(true); + onCreatePR={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreatePRDialog(true); }} - onDuplicate={(feature) => handleDuplicateFeature(feature, false)} - onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)} - onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)} - featuresWithContext={featuresWithContext} - runningAutoTasks={runningAutoTasksAllWorktrees} - onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} - onAddFeature={() => setShowAddDialog(true)} - onShowCompletedModal={() => setShowCompletedModal(true)} - completedCount={completedFeatures.length} - pipelineConfig={pipelineConfig ?? null} - onOpenPipelineSettings={() => setShowPipelineSettings(true)} - isSelectionMode={isSelectionMode} - selectionTarget={selectionTarget} - selectedFeatureIds={selectedFeatureIds} - onToggleFeatureSelection={toggleFeatureSelection} - onToggleSelectionMode={toggleSelectionMode} - isDragging={activeFeature !== null} - onAiSuggest={() => setShowPlanDialog(true)} - className="transition-opacity duration-200" + onCreateBranch={(worktree) => { + setSelectedWorktreeForAction(worktree); + setShowCreateBranchDialog(true); + }} + onAddressPRComments={handleAddressPRComments} + onAutoAddressPRComments={handleAutoAddressPRComments} + onResolveConflicts={handleResolveConflicts} + onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} + onBranchSwitchConflict={handleBranchSwitchConflict} + onStashPopConflict={handleStashPopConflict} + onStashApplyConflict={handleStashApplyConflict} + onBranchDeletedDuringMerge={(branchName) => { + batchResetBranchFeatures(branchName); + setWorktreeRefreshKey((k) => k + 1); + }} + onRemovedWorktrees={handleRemovedWorktrees} + runningFeatureIds={runningAutoTasksAllWorktrees} + branchCardCounts={branchCardCounts} + features={hookFeatures.map((f) => ({ + id: f.id, + branchName: f.branchName, + }))} /> )} -
-
+ + {/* Main Content Area */} +
+ {/* View Content - Kanban Board or List View */} + {isListView ? ( + setEditingFeature(feature), + onDelete: (featureId) => handleDeleteFeature(featureId), + onViewOutput: handleViewOutput, + onVerify: handleVerifyFeature, + onResume: handleResumeFeature, + onForceStop: handleForceStopFeature, + onManualVerify: handleManualVerify, + onFollowUp: handleOpenFollowUp, + onImplement: handleStartImplementation, + onComplete: handleCompleteFeature, + onViewPlan: (feature) => setViewPlanFeature(feature), + onApprovePlan: handleOpenApprovalDialog, + onSpawnTask: (feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }, + onDuplicate: (feature) => handleDuplicateFeature(feature, false), + onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), + onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature), + }} + runningAutoTasks={runningAutoTasksAllWorktrees} + pipelineConfig={pipelineConfig} + onAddFeature={() => setShowAddDialog(true)} + onQuickAdd={() => setShowQuickAddDialog(true)} + onTemplateSelect={handleTemplateSelect} + templates={featureTemplates} + isSelectionMode={isSelectionMode} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onRowClick={(feature) => { + if (feature.status === 'backlog') { + setEditingFeature(feature); + } else { + handleViewOutput(feature); + } + }} + className="transition-opacity duration-200" + /> + ) : ( + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} + onSpawnTask={(feature) => { + setSpawnParentFeature(feature); + setShowAddDialog(true); + }} + onDuplicate={(feature) => handleDuplicateFeature(feature, false)} + onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)} + onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasksAllWorktrees} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + onAddFeature={() => setShowAddDialog(true)} + onQuickAdd={() => setShowQuickAddDialog(true)} + onTemplateSelect={handleTemplateSelect} + templates={featureTemplates} + addFeatureShortcut={keyboardShortcuts.addFeature} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + pipelineConfig={pipelineConfig ?? null} + onOpenPipelineSettings={() => setShowPipelineSettings(true)} + isSelectionMode={isSelectionMode} + selectionTarget={selectionTarget} + selectedFeatureIds={selectedFeatureIds} + onToggleFeatureSelection={toggleFeatureSelection} + onToggleSelectionMode={toggleSelectionMode} + isDragging={activeFeature !== null} + onAiSuggest={() => setShowPlanDialog(true)} + className="transition-opacity duration-200" + /> + )} +
+ + {/* Selection Action Bar */} {isSelectionMode && ( @@ -1797,6 +1910,14 @@ export function BoardView() { forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} /> + {/* Quick Add Dialog */} + + {/* Dependency Link Dialog */} { + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPathForRefetch), + }); + }, 1500); + setSelectedWorktreeForAction(null); // 6. Force-sync settings immediately so the reset worktree diff --git a/apps/ui/src/components/views/board-view/board-error-boundary.tsx b/apps/ui/src/components/views/board-view/board-error-boundary.tsx new file mode 100644 index 00000000..98bfb254 --- /dev/null +++ b/apps/ui/src/components/views/board-view/board-error-boundary.tsx @@ -0,0 +1,74 @@ +import { Component, type ReactNode, type ErrorInfo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +const logger = createLogger('BoardErrorBoundary'); + +interface Props { + children: ReactNode; + /** Called when the user clicks "Recover" - should reset worktree to main */ + onRecover?: () => void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary for the board's content area (WorktreePanel + KanbanBoard/ListView). + * + * Catches render errors caused by stale worktree state during worktree switches + * (e.g. re-render cascades that trigger React error #185 on mobile Safari PWA). + * Instead of crashing the entire page, this shows a recovery UI that resets + * the worktree selection to main and retries rendering. + */ +export class BoardErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logger.error('Board content crashed:', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleRecover = () => { + this.setState({ hasError: false, error: null }); + this.props.onRecover?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+ +
+
+

Board crashed

+

+ A rendering error occurred, possibly during a worktree switch. Click recover to reset + to the main branch and retry. +

+
+ +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/ui/src/components/views/board-view/components/add-feature-button.tsx b/apps/ui/src/components/views/board-view/components/add-feature-button.tsx new file mode 100644 index 00000000..abf35270 --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/add-feature-button.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Plus, ChevronDown, Zap, FileText } from 'lucide-react'; +import type { FeatureTemplate } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +interface AddFeatureButtonProps { + /** Handler for the primary "Add Feature" action (opens full dialog) */ + onAddFeature: () => void; + /** Handler for Quick Add submission */ + onQuickAdd: () => void; + /** Handler for template selection */ + onTemplateSelect: (template: FeatureTemplate) => void; + /** Available templates (filtered to enabled ones) */ + templates: FeatureTemplate[]; + /** Whether to show as a small icon button or full button */ + compact?: boolean; + /** Whether the button should take full width */ + fullWidth?: boolean; + /** Additional className */ + className?: string; + /** Test ID prefix */ + testIdPrefix?: string; + /** Shortcut text to display (optional) */ + shortcut?: string; +} + +export function AddFeatureButton({ + onAddFeature, + onQuickAdd, + onTemplateSelect, + templates, + compact = false, + fullWidth = false, + className, + testIdPrefix = 'add-feature', + shortcut, +}: AddFeatureButtonProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); + + // Filter to only enabled templates and sort by order + const enabledTemplates = templates + .filter((t) => t.enabled !== false) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const handleTemplateClick = (template: FeatureTemplate) => { + setDropdownOpen(false); + onTemplateSelect(template); + }; + + if (compact) { + // Compact mode: Three small icon segments + return ( +
+ {/* Segment 1: Add Feature */} + + {/* Segment 2: Quick Add */} + + {/* Segment 3: Templates dropdown */} + {enabledTemplates.length > 0 && ( + + + + + + {enabledTemplates.map((template) => ( + handleTemplateClick(template)} + data-testid={`template-menu-item-${template.id}`} + > + + {template.name} + + ))} + + + )} +
+ ); + } + + // Full mode: Three-segment button + return ( +
+ {/* Segment 1: Add Feature */} + + {/* Segment 2: Quick Add */} + + {/* Segment 3: Templates dropdown */} + + + + + + {enabledTemplates.length > 0 ? ( + enabledTemplates.map((template) => ( + handleTemplateClick(template)} + data-testid={`template-menu-item-${template.id}`} + > + + {template.name} + + )) + ) : ( + + No templates configured + + )} + + +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 814bd0e3..dac9b302 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -5,12 +5,13 @@ import { Button } from '@/components/ui/button'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { useAppStore, formatShortcut } from '@/store/app-store'; import type { Feature } from '@/store/app-store'; -import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types'; +import type { PipelineConfig, FeatureStatusWithPipeline, FeatureTemplate } from '@automaker/types'; import { ListHeader } from './list-header'; import { ListRow, sortFeatures } from './list-row'; import { createRowActionHandlers, type RowActionHandlers } from './row-actions'; import { getStatusOrder } from './status-badge'; import { getColumnsWithPipeline } from '../../constants'; +import { AddFeatureButton } from '../add-feature-button'; import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state'; /** Empty set constant to avoid creating new instances on each render */ @@ -65,6 +66,12 @@ export interface ListViewProps { pipelineConfig?: PipelineConfig | null; /** Callback to add a new feature */ onAddFeature?: () => void; + /** Callback for quick add */ + onQuickAdd?: () => void; + /** Callback for template selection */ + onTemplateSelect?: (template: FeatureTemplate) => void; + /** Available feature templates */ + templates?: FeatureTemplate[]; /** Whether selection mode is enabled */ isSelectionMode?: boolean; /** Set of selected feature IDs */ @@ -125,7 +132,22 @@ const StatusGroupHeader = memo(function StatusGroupHeader({ /** * EmptyState displays a message when there are no features */ -const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) { +const EmptyState = memo(function EmptyState({ + onAddFeature, + onQuickAdd, + onTemplateSelect, + templates, + shortcut, +}: { + onAddFeature?: () => void; + onQuickAdd?: () => void; + onTemplateSelect?: (template: FeatureTemplate) => void; + templates?: FeatureTemplate[]; + shortcut?: string; +}) { + // Only show AddFeatureButton if all required handlers are provided + const canShowSplitButton = onAddFeature && onQuickAdd && onTemplateSelect; + return (

No features to display

- {onAddFeature && ( + {canShowSplitButton ? ( + + ) : onAddFeature ? ( - )} + ) : null}
); }); @@ -190,6 +221,9 @@ export const ListView = memo(function ListView({ runningAutoTasks, pipelineConfig = null, onAddFeature, + onQuickAdd, + onTemplateSelect, + templates = [], isSelectionMode = false, selectedFeatureIds = EMPTY_SET, onToggleFeatureSelection, @@ -388,7 +422,13 @@ export const ListView = memo(function ListView({ if (totalFeatures === 0) { return (
- +
); } @@ -452,21 +492,17 @@ export const ListView = memo(function ListView({ {/* Footer with Add Feature button, styled like board view */} - {onAddFeature && ( + {onAddFeature && onQuickAdd && onTemplateSelect && (
- +
)} diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx index afc770e7..226cf359 100644 --- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx @@ -29,7 +29,10 @@ import { useAppStore } from '@/store/app-store'; /** * Normalize PhaseModelEntry or string to PhaseModelEntry */ -function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry { +function normalizeEntry(entry: PhaseModelEntry | string | undefined | null): PhaseModelEntry { + if (!entry) { + return { model: 'claude-sonnet' as ModelAlias }; + } if (typeof entry === 'string') { return { model: entry as ModelAlias | CursorModelId }; } @@ -110,7 +113,12 @@ export function BacklogPlanDialog({ // Use model override if set, otherwise use global default (extract model string from PhaseModelEntry) const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel); const effectiveModel = effectiveModelEntry.model; - const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel); + const result = await api.backlogPlan.generate( + projectPath, + prompt, + effectiveModel, + currentBranch + ); if (!result.success) { logger.error('Backlog plan generation failed to start', { error: result.error, @@ -131,7 +139,15 @@ export function BacklogPlanDialog({ }); setPrompt(''); onClose(); - }, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]); + }, [ + projectPath, + prompt, + modelOverride, + phaseModels, + setIsGeneratingPlan, + onClose, + currentBranch, + ]); const handleApply = useCallback(async () => { if (!pendingPlanResult) return; diff --git a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx index a720b8cd..e49fdf13 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx @@ -10,12 +10,27 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { GitBranch, AlertCircle, ChevronDown, ChevronRight, Globe, RefreshCw } from 'lucide-react'; +import { + GitBranch, + AlertCircle, + ChevronDown, + ChevronRight, + Globe, + RefreshCw, + Cloud, +} from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; import { toast } from 'sonner'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; /** * Parse git/worktree error messages and return user-friendly versions @@ -113,10 +128,19 @@ export function CreateWorktreeDialog({ // allow free-form branch entry via allowCreate as a fallback. const [branchFetchError, setBranchFetchError] = useState(null); + // Remote selection state + const [selectedRemote, setSelectedRemote] = useState('local'); + const [availableRemotes, setAvailableRemotes] = useState>( + [] + ); + const [remoteBranches, setRemoteBranches] = useState< + Map> + >(new Map()); + // AbortController ref so in-flight branch fetches can be cancelled when the dialog closes const branchFetchAbortRef = useRef(null); - // Fetch available branches (local + remote) when the base branch section is expanded + // Fetch available branches and remotes when the base branch section is expanded const fetchBranches = useCallback( async (signal?: AbortSignal) => { if (!projectPath) return; @@ -125,13 +149,16 @@ export function CreateWorktreeDialog({ try { const api = getHttpApiClient(); - // Fetch branches using the project path (use listBranches on the project root). - // Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request. - const branchResult = await api.worktree.listBranches(projectPath, true, signal); + // Fetch both branches and remotes in parallel + const [branchResult, remotesResult] = await Promise.all([ + api.worktree.listBranches(projectPath, true, signal), + api.worktree.listRemotes(projectPath), + ]); // If the fetch was aborted while awaiting, bail out to avoid stale state writes if (signal?.aborted) return; + // Process branches if (branchResult.success && branchResult.result) { setBranchFetchError(null); setAvailableBranches( @@ -147,6 +174,30 @@ export function CreateWorktreeDialog({ setBranchFetchError(message); setAvailableBranches([{ name: 'main', isRemote: false }]); } + + // Process remotes + if (remotesResult.success && remotesResult.result) { + const remotes = remotesResult.result.remotes; + setAvailableRemotes( + remotes.map((r: { name: string; url: string; branches: unknown[] }) => ({ + name: r.name, + url: r.url, + })) + ); + + // Build remote branches map for filtering + const branchesMap = new Map>(); + remotes.forEach( + (r: { + name: string; + url: string; + branches: Array<{ name: string; fullRef: string }>; + }) => { + branchesMap.set(r.name, r.branches || []); + } + ); + setRemoteBranches(branchesMap); + } } catch (err) { // If aborted, don't update state if (signal?.aborted) return; @@ -160,6 +211,8 @@ export function CreateWorktreeDialog({ // and enable free-form entry (allowCreate) so the user can still type // any branch name when the remote list is unavailable. setAvailableBranches([{ name: 'main', isRemote: false }]); + setAvailableRemotes([]); + setRemoteBranches(new Map()); } finally { if (!signal?.aborted) { setIsLoadingBranches(false); @@ -198,27 +251,30 @@ export function CreateWorktreeDialog({ setAvailableBranches([]); setBranchFetchError(null); setIsLoadingBranches(false); + setSelectedRemote('local'); + setAvailableRemotes([]); + setRemoteBranches(new Map()); } }, [open]); - // Build branch name list for the autocomplete, with local branches first then remote + // Build branch name list for the autocomplete, filtered by selected remote const branchNames = useMemo(() => { - const local: string[] = []; - const remote: string[] = []; - - for (const b of availableBranches) { - if (b.isRemote) { - // Skip bare remote refs without a branch name (e.g. "origin" by itself) - if (!b.name.includes('/')) continue; - remote.push(b.name); - } else { - local.push(b.name); - } + // If "local" is selected, show only local branches + if (selectedRemote === 'local') { + return availableBranches.filter((b) => !b.isRemote).map((b) => b.name); } - // Local branches first, then remote branches - return [...local, ...remote]; - }, [availableBranches]); + // If a specific remote is selected, show only branches from that remote + const remoteBranchList = remoteBranches.get(selectedRemote); + if (remoteBranchList) { + return remoteBranchList.map((b) => b.fullRef); + } + + // Fallback: filter from available branches by remote prefix + return availableBranches + .filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`)) + .map((b) => b.name); + }, [availableBranches, selectedRemote, remoteBranches]); // Determine if the selected base branch is a remote branch. // Also detect manually entered remote-style names (e.g. "origin/feature") @@ -418,6 +474,47 @@ export function CreateWorktreeDialog({ )} + {/* Remote Selector */} +
+ + +
+ { @@ -425,9 +522,13 @@ export function CreateWorktreeDialog({ setError(null); }} branches={branchNames} - placeholder="Select base branch (default: HEAD)..." + placeholder={ + selectedRemote === 'local' + ? 'Select local branch (default: HEAD)...' + : `Select branch from ${selectedRemote}...` + } disabled={isLoadingBranches} - allowCreate={!!branchFetchError} + allowCreate={!!branchFetchError || selectedRemote === 'local'} /> {isRemoteBaseBranch && ( 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 fd1ca22a..0c636c70 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -1,4 +1,5 @@ export { AddFeatureDialog } from './add-feature-dialog'; +export { QuickAddDialog } from './quick-add-dialog'; export { AgentOutputModal } from './agent-output-modal'; export { BacklogPlanDialog } from './backlog-plan-dialog'; export { CompletedFeaturesModal } from './completed-features-modal'; diff --git a/apps/ui/src/components/views/board-view/dialogs/quick-add-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/quick-add-dialog.tsx new file mode 100644 index 00000000..d55311b4 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/quick-add-dialog.tsx @@ -0,0 +1,139 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { HotkeyButton } from '@/components/ui/hotkey-button'; +import { Textarea } from '@/components/ui/textarea'; +import { Play, Plus } from 'lucide-react'; +import type { PhaseModelEntry } from '@automaker/types'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; +import { useAppStore } from '@/store/app-store'; + +interface QuickAddDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (description: string, modelEntry: PhaseModelEntry) => void; + onAddAndStart: (description: string, modelEntry: PhaseModelEntry) => void; +} + +export function QuickAddDialog({ open, onOpenChange, onAdd, onAddAndStart }: QuickAddDialogProps) { + const [description, setDescription] = useState(''); + const [descriptionError, setDescriptionError] = useState(false); + const textareaRef = useRef(null); + + // Get default feature model from store + const defaultFeatureModel = useAppStore((s) => s.defaultFeatureModel); + const currentProject = useAppStore((s) => s.currentProject); + + // Use project-level default feature model if set, otherwise fall back to global + const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel; + + const [modelEntry, setModelEntry] = useState( + effectiveDefaultFeatureModel || { model: 'claude-opus' } + ); + + // Reset form when dialog opens (in useEffect to avoid state mutation during render) + useEffect(() => { + if (open) { + setDescription(''); + setDescriptionError(false); + setModelEntry(effectiveDefaultFeatureModel || { model: 'claude-opus' }); + } + }, [open, effectiveDefaultFeatureModel]); + + const handleSubmit = (actionFn: (description: string, modelEntry: PhaseModelEntry) => void) => { + if (!description.trim()) { + setDescriptionError(true); + textareaRef.current?.focus(); + return; + } + + actionFn(description.trim(), modelEntry); + setDescription(''); + setDescriptionError(false); + onOpenChange(false); + }; + + const handleAdd = () => handleSubmit(onAdd); + const handleAddAndStart = () => handleSubmit(onAddAndStart); + + const handleDescriptionChange = (value: string) => { + setDescription(value); + if (value.trim()) { + setDescriptionError(false); + } + }; + + return ( + + { + e.preventDefault(); + textareaRef.current?.focus(); + }} + > + + Quick Add Feature + + Create a new feature with minimal configuration. All other settings use defaults. + + + +
+ {/* Description Input */} +
+ +