Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.
This commit is contained in:
gsxdsm
2026-02-22 20:48:09 -08:00
committed by GitHub
parent 9305ecc242
commit e7504b247f
70 changed files with 3141 additions and 560 deletions

View File

@@ -282,11 +282,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
} }
/** /**
* Build system prompt configuration based on autoLoadClaudeMd setting. * Build system prompt and settingSources based on two independent settings:
* When autoLoadClaudeMd is true: * - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading * - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
* - If there's a custom systemPrompt, appends it to the preset *
* - Sets settingSources to ['project'] 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 * @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options * @returns Object with systemPrompt and settingSources for SDK options
@@ -295,27 +299,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig; systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>; 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: { const result: {
systemPrompt: SystemPromptConfig; systemPrompt?: string | SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>; settingSources?: Array<'user' | 'project' | 'local'>;
} = { } = {};
systemPrompt: {
// 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', type: 'preset',
preset: 'claude_code', preset: 'claude_code',
}, };
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings // If there's a custom system prompt, append it to the preset
settingSources: ['user', 'project'], 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 // Determine settingSources based on autoLoadClaudeMd
if (config.systemPrompt) { if (config.autoLoadClaudeMd) {
result.systemPrompt.append = config.systemPrompt; // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
result.settingSources = ['user', 'project'];
} }
return result; return result;
@@ -323,12 +334,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
/** /**
* System prompt configuration for SDK options * 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 { 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'; 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'; preset: 'claude_code';
/** Optional additional prompt to append to the preset */ /** Optional additional prompt to append to the preset */
append?: string; append?: string;
@@ -362,6 +375,9 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean; 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 */ /** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;

View File

@@ -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<boolean> {
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. * Get the default max turns setting from global settings.
* *

View File

@@ -33,8 +33,23 @@ const logger = createLogger('ClaudeProvider');
*/ */
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
// System vars are always passed from process.env regardless of profile // System vars are always passed from process.env regardless of profile.
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL']; // 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) * Check if the config is a ClaudeCompatibleProvider (new system)
@@ -213,6 +228,8 @@ export class ClaudeProvider extends BaseProvider {
env: buildEnv(providerConfig, credentials), env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts) // Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }), ...(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 // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,

View File

@@ -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.'; 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')) { 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 // Rate limiting

View File

@@ -3,6 +3,9 @@
* *
* Model is configurable via phaseModels.backlogPlanningModel in settings * Model is configurable via phaseModels.backlogPlanningModel in settings
* (defaults to Sonnet). Can be overridden per-call via model parameter. * (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'; import type { EventEmitter } from '../../lib/events.js';
@@ -12,8 +15,10 @@ import {
isCursorModel, isCursorModel,
stripProviderPrefix, stripProviderPrefix,
type ThinkingLevel, type ThinkingLevel,
type SystemPromptPreset,
} from '@automaker/types'; } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver'; import { resolvePhaseModel } from '@automaker/model-resolver';
import { getCurrentBranch } from '@automaker/git-utils';
import { FeatureLoader } from '../../services/feature-loader.js'; import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js'; import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -27,10 +32,27 @@ import {
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
getPromptCustomization, getPromptCustomization,
getPhaseModelWithOverrides, getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js'; } 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(); 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<BacklogPlanResult>(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 * Generate a backlog modification plan based on user prompt
*/ */
@@ -93,11 +162,40 @@ export async function generateBacklogPlan(
events: EventEmitter, events: EventEmitter,
abortController: AbortController, abortController: AbortController,
settingsService?: SettingsService, settingsService?: SettingsService,
model?: string model?: string,
branchName?: string
): Promise<BacklogPlanResult> { ): Promise<BacklogPlanResult> {
try { try {
// Load current features // 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', { events.emit('backlog-plan:event', {
type: 'backlog_plan_progress', type: 'backlog_plan_progress',
@@ -162,17 +260,23 @@ export async function generateBacklogPlan(
// Strip provider prefix - providers expect bare model IDs // Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(effectiveModel); const bareModel = stripProviderPrefix(effectiveModel);
// Get autoLoadClaudeMd setting // Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath, projectPath,
settingsService, settingsService,
'[BacklogPlan]' '[BacklogPlan]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// For Cursor models, we need to combine prompts with explicit instructions // For Cursor models, we need to combine prompts with explicit instructions
// because Cursor doesn't support systemPrompt separation like Claude SDK // because Cursor doesn't support systemPrompt separation like Claude SDK
let finalPrompt = userPrompt; let finalPrompt = userPrompt;
let finalSystemPrompt: string | undefined = systemPrompt; let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
if (isCursorModel(effectiveModel)) { if (isCursorModel(effectiveModel)) {
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions'); logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
@@ -187,54 +291,141 @@ CRITICAL INSTRUCTIONS:
${userPrompt}`; ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt 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 // Execute the query with retry logic for transient CLI failures
const stream = provider.executeQuery({ const queryOptions = {
prompt: finalPrompt, prompt: finalPrompt,
model: bareModel, model: bareModel,
cwd: projectPath, cwd: projectPath,
systemPrompt: finalSystemPrompt, systemPrompt: finalSystemPrompt,
maxTurns: 1, maxTurns: 1,
allowedTools: [], // No tools needed for this tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
abortController, abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, settingSources: finalSettingSources,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking thinkingLevel, // Pass thinking level for extended thinking
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource credentials, // Pass credentials for resolving 'credentials' apiKeySource
}); };
let responseText = ''; 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) { if (abortController.signal.aborted) {
throw new Error('Generation aborted'); throw new Error('Generation aborted');
} }
if (msg.type === 'assistant') { if (attempt > 0) {
if (msg.message?.content) { logger.info(
for (const block of msg.message.content) { `[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
if (block.type === 'text') { );
responseText += block.text; 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) responseText = selectBestResponseText(accumulatedText, providerResultText);
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
logger.info('[BacklogPlan] Previous responseText length:', responseText.length); // If we got here, the stream completed successfully
if (msg.result.length > responseText.length) { lastError = null;
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)'); break;
responseText = msg.result; } catch (error) {
} else { lastError = error;
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)'); 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 // Parse the response
const result = parsePlanResponse(responseText); const result = recoveredResult ?? parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, { await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),

View File

@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, prompt, model } = req.body as { const { projectPath, prompt, model, branchName } = req.body as {
projectPath: string; projectPath: string;
prompt: string; prompt: string;
model?: string; model?: string;
branchName?: string;
}; };
if (!projectPath) { if (!projectPath) {
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return; return;
} }
setRunningState(true); const abortController = new AbortController();
setRunningState(true, abortController);
setRunningDetails({ setRunningDetails({
projectPath, projectPath,
prompt, prompt,
model, model,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
}); });
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background // Start generation in background
// Note: generateBacklogPlan handles its own error event emission, // Note: generateBacklogPlan handles its own error event emission
// so we only log here to avoid duplicate error toasts // and state cleanup in its finally block, so we only log here
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model) generateBacklogPlan(
.catch((error) => { projectPath,
// Just log - error event already emitted by generateBacklogPlan prompt,
logError(error, 'Generate backlog plan failed (background)'); events,
}) abortController,
.finally(() => { settingsService,
setRunningState(false, null); model,
setRunningDetails(null); branchName
}); ).catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
});
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {

View File

@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
if (!rawMessage) return baseResponse; 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 { return {
statusCode: 503, statusCode: 503,
userMessage: 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.`,
'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.',
}; };
} }

View File

@@ -170,127 +170,125 @@ export function createGeneratePRDescriptionHandler(
// Determine the base branch for comparison // Determine the base branch for comparison
const base = baseBranch || 'main'; const base = baseBranch || 'main';
// Get the diff between current branch and base branch (committed changes) // Collect diffs in three layers and combine them:
// Track whether the diff method used only includes committed changes. // 1. Committed changes on the branch: `git diff base...HEAD`
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes, // 2. Staged (cached) changes not yet committed: `git diff --cached`
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already // 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
// include uncommitted working directory changes. //
let diff = ''; // Untracked files are intentionally excluded — they are typically build artifacts,
let diffIncludesUncommitted = false; // 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<string, string> {
const fileHunks = new Map<string, string>();
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 { try {
// First, try to get diff against the base branch const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5,
}); });
diff = branchDiff; committedDiff = stdout;
// git diff base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch { } catch {
// If branch comparison fails (e.g., base branch doesn't exist locally), // Base branch may not exist locally; try the remote tracking branch
// try fetching and comparing against remote base
try { try {
const { stdout: remoteDiff } = await execFileAsync( const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
'git', cwd: worktreePath,
['diff', `origin/${base}...HEAD`], maxBuffer: 1024 * 1024 * 5,
{ });
cwd: worktreePath, committedDiff = stdout;
maxBuffer: 1024 * 1024 * 5,
}
);
diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch { } catch {
// Fall back to getting all uncommitted + committed changes // Cannot compare against base — leave committedDiff empty; the uncommitted
try { // changes gathered below will still be included.
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], { logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
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;
}
} }
} }
// Check for uncommitted changes (staged + unstaged) to include in the description. // --- Step 2: staged changes (tracked files only) ---
// When creating a PR, uncommitted changes will be auto-committed, so they should be let stagedDiff = '';
// 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;
try { try {
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], { const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
}); });
hasUncommittedChanges = statusOutput.trim().length > 0; stagedDiff = stdout;
} catch (err) {
if (hasUncommittedChanges && !diffIncludesUncommitted) { // Non-fatal — staged diff is a best-effort supplement
logger.info('Uncommitted changes detected, including in PR description context'); logger.debug('Failed to get staged diff', err);
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
} }
// 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<string, string>();
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 = ''; let commitLog = '';
try { try {
const { stdout: logOutput } = await execFileAsync( const { stdout: logOutput } = await execFileAsync(
@@ -303,11 +301,11 @@ export function createGeneratePRDescriptionHandler(
); );
commitLog = logOutput.trim(); commitLog = logOutput.trim();
} catch { } catch {
// If comparing against base fails, fall back to recent commits // Base branch not available locally — try the remote tracking branch
try { try {
const { stdout: logOutput } = await execFileAsync( const { stdout: logOutput } = await execFileAsync(
'git', 'git',
['log', '--oneline', '-10', '--no-decorate'], ['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
{ {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024, maxBuffer: 1024 * 1024,
@@ -315,7 +313,9 @@ export function createGeneratePRDescriptionHandler(
); );
commitLog = logOutput.trim(); commitLog = logOutput.trim();
} catch { } 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`; 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) { if (truncatedDiff) {
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
} }

View File

@@ -9,6 +9,7 @@ import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process'; import { exec, execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js'; import { getErrorMessage, logWorktreeError } from '../common.js';
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -131,6 +132,8 @@ export function createListBranchesHandler() {
let behindCount = 0; let behindCount = 0;
let hasRemoteBranch = false; let hasRemoteBranch = false;
let trackingRemote: string | undefined; let trackingRemote: string | undefined;
// List of remote names that have a branch matching the current branch name
let remotesWithBranch: string[] = [];
try { try {
// First check if there's a remote tracking branch // First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execFileAsync( 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({ res.json({
success: true, success: true,
result: { result: {
@@ -182,6 +191,7 @@ export function createListBranchesHandler() {
hasRemoteBranch, hasRemoteBranch,
hasAnyRemotes, hasAnyRemotes,
trackingRemote, trackingRemote,
remotesWithBranch,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -5,6 +5,7 @@
import type { import type {
PlanningMode, PlanningMode,
ThinkingLevel, ThinkingLevel,
ReasoningEffort,
ParsedTask, ParsedTask,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
Credentials, Credentials,
@@ -24,7 +25,9 @@ export interface AgentExecutionOptions {
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null; branchName?: string | null;
credentials?: Credentials; credentials?: Credentials;
claudeCompatibleProvider?: ClaudeCompatibleProvider; claudeCompatibleProvider?: ClaudeCompatibleProvider;

View File

@@ -128,6 +128,7 @@ export class AgentExecutor {
? (mcpServers as Record<string, { command: string }>) ? (mcpServers as Record<string, { command: string }>)
: undefined, : undefined,
thinkingLevel: options.thinkingLevel, thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
credentials, credentials,
claudeCompatibleProvider, claudeCompatibleProvider,
sdkSessionId, sdkSessionId,
@@ -703,6 +704,7 @@ export class AgentExecutor {
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController, abortController: o.abortController,
thinkingLevel: o.thinkingLevel, thinkingLevel: o.thinkingLevel,
reasoningEffort: o.reasoningEffort,
mcpServers: mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0 o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>) ? (o.mcpServers as Record<string, { command: string }>)

View File

@@ -21,6 +21,7 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import { import {
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
getMCPServersFromSettings, getMCPServersFromSettings,
getPromptCustomization, getPromptCustomization,
@@ -357,6 +358,22 @@ export class AgentService {
'[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) // Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
@@ -443,6 +460,7 @@ export class AgentService {
systemPrompt: combinedSystemPrompt, systemPrompt: combinedSystemPrompt,
abortController: session.abortController!, abortController: session.abortController!,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
maxTurns: userMaxTurns, // User-configured max turns from settings maxTurns: userMaxTurns, // User-configured max turns from settings
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,

View File

@@ -14,7 +14,7 @@
import path from 'path'; import path from 'path';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; 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 { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
@@ -213,7 +213,9 @@ export class AutoModeServiceFacade {
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null; branchName?: string | null;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -244,6 +246,7 @@ export class AutoModeServiceFacade {
// internal defaults which may be much lower than intended (e.g., Codex CLI's // internal defaults which may be much lower than intended (e.g., Codex CLI's
// default turn limit can cause feature runs to stop prematurely). // default turn limit can cause feature runs to stop prematurely).
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false; const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true;
let mcpServers: Record<string, unknown> | undefined; let mcpServers: Record<string, unknown> | undefined;
try { try {
if (settingsService) { if (settingsService) {
@@ -265,6 +268,7 @@ export class AutoModeServiceFacade {
systemPrompt: opts?.systemPrompt, systemPrompt: opts?.systemPrompt,
abortController, abortController,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel, thinkingLevel: opts?.thinkingLevel,
maxTurns: userMaxTurns, maxTurns: userMaxTurns,
mcpServers: mcpServers as mcpServers: mcpServers as
@@ -292,7 +296,9 @@ export class AutoModeServiceFacade {
previousContent: opts?.previousContent as string | undefined, previousContent: opts?.previousContent as string | undefined,
systemPrompt: opts?.systemPrompt as string | undefined, systemPrompt: opts?.systemPrompt as string | undefined,
autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
branchName: opts?.branchName as string | null | undefined, branchName: opts?.branchName as string | null | undefined,
provider, provider,
effectiveBareModel, effectiveBareModel,

View File

@@ -64,6 +64,8 @@ interface AutoModeEventPayload {
error?: string; error?: string;
errorType?: string; errorType?: string;
projectPath?: string; projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
} }
/** /**
@@ -75,6 +77,28 @@ interface FeatureCreatedPayload {
projectPath: string; 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 * Event Hook Service
* *
@@ -82,12 +106,30 @@ interface FeatureCreatedPayload {
* Also stores events to history for debugging and replay. * Also stores events to history for debugging and replay.
*/ */
export class EventHookService { 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 emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null; private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null; private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | null = null; private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | 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<string>();
/**
* Timer IDs for pending cleanup of recentlyHandledFeatures entries,
* keyed by featureId. Stored so they can be cancelled in destroy().
*/
private recentlyHandledTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** /**
* Initialize the service with event emitter, settings service, event history service, and feature loader * 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();
this.unsubscribe = null; 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.emitter = null;
this.settingsService = null; this.settingsService = null;
this.eventHistoryService = null; this.eventHistoryService = null;
@@ -140,14 +188,27 @@ export class EventHookService {
switch (payload.type) { switch (payload.type) {
case 'auto_mode_feature_complete': case 'auto_mode_feature_complete':
trigger = payload.passes ? 'feature_success' : 'feature_error'; 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; break;
case 'auto_mode_error': case 'auto_mode_error':
// Feature-level error (has featureId) vs auto-mode level error // Feature-level error (has featureId) vs auto-mode level error
trigger = payload.featureId ? 'feature_error' : 'auto_mode_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; break;
case 'auto_mode_idle': case 'auto_mode_idle':
trigger = 'auto_mode_complete'; trigger = 'auto_mode_complete';
break; break;
case 'feature_status_changed':
if (isFeatureStatusChangedPayload(payload)) {
this.handleFeatureStatusChanged(payload);
}
return;
default: default:
// Other event types don't trigger hooks // Other event types don't trigger hooks
return; return;
@@ -203,6 +264,74 @@ export class EventHookService {
await this.executeHooksForTrigger('feature_created', context); 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<void> {
// 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 * Execute all enabled hooks matching the given trigger and store event to history
*/ */

View File

@@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js';
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -241,6 +242,11 @@ ${feature.spec}
this.settingsService, this.settingsService,
'[ExecutionService]' '[ExecutionService]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[ExecutionService]'
);
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
let prompt: string; let prompt: string;
const contextResult = await this.loadContextFilesFn({ const contextResult = await this.loadContextFilesFn({
@@ -289,7 +295,9 @@ ${feature.spec}
requirePlanApproval: feature.requirePlanApproval, requirePlanApproval: feature.requirePlanApproval,
systemPrompt: combinedSystemPrompt || undefined, systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
branchName: feature.branchName ?? null, branchName: feature.branchName ?? null,
} }
); );
@@ -353,7 +361,9 @@ Please continue from where you left off and complete all remaining tasks. Use th
requirePlanApproval: false, requirePlanApproval: false,
systemPrompt: combinedSystemPrompt || undefined, systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
branchName: feature.branchName ?? null, 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, branchName: feature.branchName ?? null,
abortController, abortController,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}); });

View File

@@ -5,7 +5,7 @@
* allowing the service to delegate to other services without circular dependencies. * 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 { loadContextFiles } from '@automaker/utils';
import type { PipelineContext } from './pipeline-orchestrator.js'; import type { PipelineContext } from './pipeline-orchestrator.js';
@@ -31,7 +31,9 @@ export type RunAgentFn = (
previousContent?: string; previousContent?: string;
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null; branchName?: string | null;
} }
) => Promise<void>; ) => Promise<void>;

View File

@@ -16,6 +16,7 @@ import * as secureFs from '../lib/secure-fs.js';
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
} from '../lib/settings-helpers.js'; } from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js'; import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -70,8 +71,16 @@ export class PipelineOrchestrator {
) {} ) {}
async executePipeline(ctx: PipelineContext): Promise<void> { async executePipeline(ctx: PipelineContext): Promise<void> {
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } = const {
ctx; projectPath,
featureId,
feature,
steps,
workDir,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
} = ctx;
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const contextResult = await this.loadContextFilesFn({ const contextResult = await this.loadContextFilesFn({
projectPath, projectPath,
@@ -121,7 +130,9 @@ export class PipelineOrchestrator {
previousContent: previousContext, previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined, systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
} }
); );
try { try {
@@ -354,6 +365,11 @@ export class PipelineOrchestrator {
this.settingsService, this.settingsService,
'[AutoMode]' '[AutoMode]'
); );
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
const context: PipelineContext = { const context: PipelineContext = {
projectPath, projectPath,
featureId, featureId,
@@ -364,6 +380,7 @@ export class PipelineOrchestrator {
branchName: branchName ?? null, branchName: branchName ?? null,
abortController, abortController,
autoLoadClaudeMd, autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0, testAttempts: 0,
maxTestAttempts: 5, maxTestAttempts: 5,
}; };
@@ -462,7 +479,14 @@ export class PipelineOrchestrator {
projectPath, projectPath,
undefined, undefined,
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 // Only capture assertion details when they appear in failure context
// or match explicit assertion error / expect patterns // or match explicit assertion error / expect patterns
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) { if (trimmed.includes('AssertionError')) {
failedTests.push(trimmed); failedTests.push(trimmed);
} else if ( } else if (
inFailureContext && inFailureContext &&

View File

@@ -14,6 +14,7 @@ export interface PipelineContext {
branchName: string | null; branchName: string | null;
abortController: AbortController; abortController: AbortController;
autoLoadClaudeMd: boolean; autoLoadClaudeMd: boolean;
useClaudeCodeSystemPrompt?: boolean;
testAttempts: number; testAttempts: number;
maxTestAttempts: number; maxTestAttempts: number;
} }

View File

@@ -31,6 +31,7 @@ import type {
WorktreeInfo, WorktreeInfo,
PhaseModelConfig, PhaseModelConfig,
PhaseModelEntry, PhaseModelEntry,
FeatureTemplate,
ClaudeApiProfile, ClaudeApiProfile,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
ProviderModel, ProviderModel,
@@ -40,6 +41,7 @@ import {
DEFAULT_CREDENTIALS, DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION, SETTINGS_VERSION,
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,
@@ -139,6 +141,11 @@ export class SettingsService {
// Migrate model IDs to canonical format // Migrate model IDs to canonical format
const migratedModelSettings = this.migrateModelSettings(settings); 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) // Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = { let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS, ...DEFAULT_GLOBAL_SETTINGS,
@@ -149,6 +156,7 @@ export class SettingsService {
...settings.keyboardShortcuts, ...settings.keyboardShortcuts,
}, },
phaseModels: migratedPhaseModels, phaseModels: migratedPhaseModels,
featureTemplates: mergedFeatureTemplates,
}; };
// Version-based migrations // Version-based migrations
@@ -250,6 +258,32 @@ export class SettingsService {
return result; 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 * Migrate legacy enhancementModel/validationModel fields to phaseModels structure
* *

View File

@@ -8,9 +8,64 @@
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.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<string[]> {
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 * Error thrown when one or more file copy operations fail during
* `copyConfiguredFiles`. The caller can inspect `failures` for details. * `copyConfiguredFiles`. The caller can inspect `failures` for details.

View File

@@ -23,6 +23,7 @@ export type {
PhaseModelConfig, PhaseModelConfig,
PhaseModelKey, PhaseModelKey,
PhaseModelEntry, PhaseModelEntry,
FeatureTemplate,
// Claude-compatible provider types // Claude-compatible provider types
ApiKeySource, ApiKeySource,
ClaudeCompatibleProviderType, ClaudeCompatibleProviderType,
@@ -41,6 +42,7 @@ export {
DEFAULT_CREDENTIALS, DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS, DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS, DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION, SETTINGS_VERSION,
CREDENTIALS_VERSION, CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION, PROJECT_SETTINGS_VERSION,

View File

@@ -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<ProviderMessage> {
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<ProviderMessage> {
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,
})
);
});
});

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock
.calls[1][0];
expect(secondCall.trigger).toBe('feature_success');
expect(secondCall.featureId).toBe('feat-2');
});
});
describe('error context for error events', () => { describe('error context for error events', () => {
it('should use payload.error when available for error triggers', async () => { it('should use payload.error when available for error triggers', async () => {
service.initialize( service.initialize(

View File

@@ -34,6 +34,7 @@ import { getFeatureDir } from '@automaker/platform';
import { import {
getPromptCustomization, getPromptCustomization,
getAutoLoadClaudeMdSetting, getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext, filterClaudeMdFromContext,
} from '../../../src/lib/settings-helpers.js'; } from '../../../src/lib/settings-helpers.js';
import { extractSummary } from '../../../src/services/spec-parser.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), getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
})); }));
@@ -230,6 +232,7 @@ describe('execution-service.ts', () => {
}, },
} as Awaited<ReturnType<typeof getPromptCustomization>>); } as Awaited<ReturnType<typeof getPromptCustomization>>);
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
vi.mocked(getUseClaudeCodeSystemPromptSetting).mockResolvedValue(true);
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
// Re-setup spec-parser mock // Re-setup spec-parser mock

View File

@@ -57,6 +57,7 @@ vi.mock('../../../src/lib/settings-helpers.js', () => ({
}, },
}), }),
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
})); }));

View File

@@ -40,14 +40,12 @@ export function ProjectSwitcher() {
const location = useLocation(); const location = useLocation();
const { hideWiki } = SIDEBAR_FEATURE_FLAGS; const { hideWiki } = SIDEBAR_FEATURE_FLAGS;
const isWikiActive = location.pathname === '/wiki'; const isWikiActive = location.pathname === '/wiki';
const { const projects = useAppStore((s) => s.projects);
projects, const currentProject = useAppStore((s) => s.currentProject);
currentProject, const setCurrentProject = useAppStore((s) => s.setCurrentProject);
setCurrentProject, const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject);
upsertAndSetCurrentProject, const specCreatingForProject = useAppStore((s) => s.specCreatingForProject);
specCreatingForProject, const setSpecCreatingForProject = useAppStore((s) => s.setSpecCreatingForProject);
setSpecCreatingForProject,
} = useAppStore();
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null); const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>( const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null null
@@ -104,6 +102,10 @@ export function ProjectSwitcher() {
const handleProjectClick = useCallback( const handleProjectClick = useCallback(
async (project: Project) => { async (project: Project) => {
if (project.id === currentProject?.id) {
navigate({ to: '/board' });
return;
}
try { try {
// Ensure .automaker directory structure exists before switching // Ensure .automaker directory structure exists before switching
await initializeProject(project.path); await initializeProject(project.path);
@@ -124,7 +126,7 @@ export function ProjectSwitcher() {
navigate({ to: '/board' }); navigate({ to: '/board' });
}); });
}, },
[setCurrentProject, navigate] [currentProject?.id, setCurrentProject, navigate]
); );
const handleNewProject = () => { const handleNewProject = () => {

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, startTransition } from 'react';
import { import {
Folder, Folder,
ChevronDown, ChevronDown,
@@ -78,21 +78,22 @@ export function ProjectSelectorWithOptions({
setShowDeleteProjectDialog, setShowDeleteProjectDialog,
setShowRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog,
}: ProjectSelectorWithOptionsProps) { }: ProjectSelectorWithOptionsProps) {
const { const projects = useAppStore((s) => s.projects);
projects, const currentProject = useAppStore((s) => s.currentProject);
currentProject, const projectHistory = useAppStore((s) => s.projectHistory);
projectHistory, const setCurrentProject = useAppStore((s) => s.setCurrentProject);
setCurrentProject, const reorderProjects = useAppStore((s) => s.reorderProjects);
reorderProjects, const cyclePrevProject = useAppStore((s) => s.cyclePrevProject);
cyclePrevProject, const cycleNextProject = useAppStore((s) => s.cycleNextProject);
cycleNextProject, const clearProjectHistory = useAppStore((s) => s.clearProjectHistory);
clearProjectHistory,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching // Wrap setCurrentProject to ensure .automaker is initialized before switching
const setCurrentProjectWithInit = useCallback( const setCurrentProjectWithInit = useCallback(
async (p: Project) => { async (p: Project) => {
if (p.id === currentProject?.id) {
return;
}
try { try {
// Ensure .automaker directory structure exists before switching // Ensure .automaker directory structure exists before switching
await initializeProject(p.path); await initializeProject(p.path);
@@ -101,9 +102,12 @@ export function ProjectSelectorWithOptions({
// Continue with switch even if initialization fails - // Continue with switch even if initialization fails -
// the project may already be initialized // 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 { const {

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, startTransition } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { ChevronsUpDown, Folder, Plus, FolderOpen, LogOut } from 'lucide-react'; import { ChevronsUpDown, Folder, Plus, FolderOpen, LogOut } from 'lucide-react';
import * as LucideIcons 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 { cn, isMac } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants'; import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -36,7 +37,8 @@ export function SidebarHeader({
setShowRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog,
}: SidebarHeaderProps) { }: SidebarHeaderProps) {
const navigate = useNavigate(); 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 [dropdownOpen, setDropdownOpen] = useState(false);
const handleLogoClick = useCallback(() => { const handleLogoClick = useCallback(() => {
@@ -44,12 +46,29 @@ export function SidebarHeader({
}, [navigate]); }, [navigate]);
const handleProjectSelect = useCallback( const handleProjectSelect = useCallback(
(project: Project) => { async (project: Project) => {
setCurrentProject(project); if (project.id === currentProject?.id) {
setDropdownOpen(false); setDropdownOpen(false);
navigate({ to: '/board' }); 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 => { const getIconComponent = (project: Project): LucideIcon => {

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect, startTransition } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { useNavigate, useLocation } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { PanelLeftClose, ChevronDown } from 'lucide-react'; import { PanelLeftClose, ChevronDown } from 'lucide-react';
@@ -281,6 +281,27 @@ export function Sidebar() {
// Register keyboard shortcuts // Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts); 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) // Keyboard shortcuts for project switching (1-9, 0)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -305,15 +326,14 @@ export function Sidebar() {
if (projectIndex !== null && projectIndex < projects.length) { if (projectIndex !== null && projectIndex < projects.length) {
const targetProject = projects[projectIndex]; const targetProject = projects[projectIndex];
if (targetProject && targetProject.id !== currentProject?.id) { if (targetProject && targetProject.id !== currentProject?.id) {
setCurrentProject(targetProject); void switchProjectSafely(targetProject);
navigate({ to: '/board' });
} }
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, currentProject, setCurrentProject, navigate]); }, [projects, currentProject, switchProjectSafely]);
const isActiveRoute = (id: string) => { const isActiveRoute = (id: string) => {
const routePath = id === 'welcome' ? '/' : `/${id}`; const routePath = id === 'welcome' ? '/' : `/${id}`;

View File

@@ -29,8 +29,13 @@ export interface UseModelOverrideResult {
/** /**
* Normalize PhaseModelEntry or string to PhaseModelEntry * 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') { if (typeof entry === 'string') {
return { model: entry as ModelId }; return { model: entry as ModelId };
} }

View File

@@ -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 { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client'; 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 { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -58,6 +62,7 @@ import {
FollowUpDialog, FollowUpDialog,
PlanApprovalDialog, PlanApprovalDialog,
MergeRebaseDialog, MergeRebaseDialog,
QuickAddDialog,
} from './board-view/dialogs'; } from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs'; import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
@@ -75,6 +80,7 @@ import type {
StashPopConflictInfo, StashPopConflictInfo,
StashApplyConflictInfo, StashApplyConflictInfo,
} from './board-view/worktree-panel/types'; } from './board-view/worktree-panel/types';
import { BoardErrorBoundary } from './board-view/board-error-boundary';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import { import {
useBoardFeatures, useBoardFeatures,
@@ -124,6 +130,7 @@ export function BoardView() {
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
setPipelineConfig, setPipelineConfig,
featureTemplates,
} = useAppStore( } = useAppStore(
useShallow((state) => ({ useShallow((state) => ({
currentProject: state.currentProject, currentProject: state.currentProject,
@@ -142,8 +149,11 @@ export function BoardView() {
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch, isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch, getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
setPipelineConfig: state.setPipelineConfig, 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 // Fetch pipeline config via React Query
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path); const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -165,6 +175,7 @@ export function BoardView() {
} = useBoardFeatures({ currentProject }); } = useBoardFeatures({ currentProject });
const [editingFeature, setEditingFeature] = useState<Feature | null>(null); const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [showQuickAddDialog, setShowQuickAddDialog] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null); const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
@@ -418,7 +429,7 @@ export function BoardView() {
(branchName: string) => { (branchName: string) => {
const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id); const affectedIds = hookFeatures.filter((f) => f.branchName === branchName).map((f) => f.id);
if (affectedIds.length === 0) return; if (affectedIds.length === 0) return;
const updates: Partial<Feature> = { branchName: null }; const updates: Partial<Feature> = { branchName: undefined };
batchUpdateFeatures(affectedIds, updates); batchUpdateFeatures(affectedIds, updates);
for (const id of affectedIds) { for (const id of affectedIds) {
persistFeatureUpdate(id, updates).catch((err: unknown) => { persistFeatureUpdate(id, updates).catch((err: unknown) => {
@@ -642,6 +653,15 @@ export function BoardView() {
); );
}, [hookFeatures, worktrees]); }, [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 // Helper function to add and select a worktree
const addAndSelectWorktree = useCallback( const addAndSelectWorktree = useCallback(
(worktreeResult: { path: string; branch: string }) => { (worktreeResult: { path: string; branch: string }) => {
@@ -992,6 +1012,87 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation] [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 // Handler for managing PR comments - opens the PR Comment Resolution dialog
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => { const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
setPRCommentDialogPRInfo({ setPRCommentDialogPRInfo({
@@ -1561,147 +1662,159 @@ export function BoardView() {
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */} {/* BoardErrorBoundary catches render errors during worktree switches (e.g. React
<DndContext error #185 re-render cascades on mobile Safari PWA) and provides a recovery UI
sensors={sensors} that resets to main branch instead of crashing the entire page. */}
collisionDetection={collisionDetectionStrategy} <BoardErrorBoundary onRecover={handleBoardRecover}>
onDragStart={handleDragStart} {/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
onDragEnd={handleDragEnd} <DndContext
> sensors={sensors}
{/* Worktree Panel - conditionally rendered based on visibility setting */} collisionDetection={collisionDetectionStrategy}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( onDragStart={handleDragStart}
<WorktreePanel onDragEnd={handleDragEnd}
refreshTrigger={worktreeRefreshKey} >
projectPath={currentProject.path} {/* Worktree Panel - conditionally rendered based on visibility setting */}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)} {(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
onDeleteWorktree={(worktree) => { <WorktreePanel
setSelectedWorktreeForAction(worktree); refreshTrigger={worktreeRefreshKey}
setShowDeleteWorktreeDialog(true); projectPath={currentProject.path}
}} onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onCommit={(worktree) => { onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree); setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true); setShowDeleteWorktreeDialog(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 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => 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} onCommit={(worktree) => {
pipelineConfig={pipelineConfig} setSelectedWorktreeForAction(worktree);
onAddFeature={() => setShowAddDialog(true)} setShowCommitWorktreeDialog(true);
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}} }}
className="transition-opacity duration-200" onCreatePR={(worktree) => {
/> setSelectedWorktreeForAction(worktree);
) : ( setShowCreatePRDialog(true);
<KanbanBoard
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => 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)} onCreateBranch={(worktree) => {
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)} setSelectedWorktreeForAction(worktree);
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)} setShowCreateBranchDialog(true);
featuresWithContext={featuresWithContext} }}
runningAutoTasks={runningAutoTasksAllWorktrees} onAddressPRComments={handleAddressPRComments}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} onAutoAddressPRComments={handleAutoAddressPRComments}
onAddFeature={() => setShowAddDialog(true)} onResolveConflicts={handleResolveConflicts}
onShowCompletedModal={() => setShowCompletedModal(true)} onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
completedCount={completedFeatures.length} onBranchSwitchConflict={handleBranchSwitchConflict}
pipelineConfig={pipelineConfig ?? null} onStashPopConflict={handleStashPopConflict}
onOpenPipelineSettings={() => setShowPipelineSettings(true)} onStashApplyConflict={handleStashApplyConflict}
isSelectionMode={isSelectionMode} onBranchDeletedDuringMerge={(branchName) => {
selectionTarget={selectionTarget} batchResetBranchFeatures(branchName);
selectedFeatureIds={selectedFeatureIds} setWorktreeRefreshKey((k) => k + 1);
onToggleFeatureSelection={toggleFeatureSelection} }}
onToggleSelectionMode={toggleSelectionMode} onRemovedWorktrees={handleRemovedWorktrees}
isDragging={activeFeature !== null} runningFeatureIds={runningAutoTasksAllWorktrees}
onAiSuggest={() => setShowPlanDialog(true)} branchCardCounts={branchCardCounts}
className="transition-opacity duration-200" features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/> />
)} )}
</div>
</DndContext> {/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => 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"
/>
) : (
<KanbanBoard
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => 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"
/>
)}
</div>
</DndContext>
</BoardErrorBoundary>
{/* Selection Action Bar */} {/* Selection Action Bar */}
{isSelectionMode && ( {isSelectionMode && (
@@ -1797,6 +1910,14 @@ export function BoardView() {
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/> />
{/* Quick Add Dialog */}
<QuickAddDialog
open={showQuickAddDialog}
onOpenChange={setShowQuickAddDialog}
onAdd={handleQuickAdd}
onAddAndStart={handleQuickAddAndStart}
/>
{/* Dependency Link Dialog */} {/* Dependency Link Dialog */}
<DependencyLinkDialog <DependencyLinkDialog
open={Boolean(pendingDependencyLink)} open={Boolean(pendingDependencyLink)}
@@ -2022,14 +2143,19 @@ export function BoardView() {
// cascade into React error #185. // cascade into React error #185.
batchResetBranchFeatures(deletedWorktree.branch); batchResetBranchFeatures(deletedWorktree.branch);
// 5. Do NOT trigger setWorktreeRefreshKey here. The optimistic // 5. Schedule a deferred refetch to reconcile with the server.
// cache update (step 3) already removed the worktree from // The server has already completed the deletion, so this
// both the Zustand store and React Query cache. Incrementing // refetch will return data without the deleted worktree.
// the refresh key would cause invalidateQueries → server // This protects against stale in-flight polling responses
// refetch, and if the server's .worktrees/ directory scan // that may slip through the cancelQueries window and
// finds remnants of the deleted worktree, it would re-add // overwrite the optimistic update above.
// it to the dropdown. The 30-second polling interval in const projectPathForRefetch = currentProject.path;
// WorktreePanel will eventually reconcile with the server. setTimeout(() => {
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPathForRefetch),
});
}, 1500);
setSelectedWorktreeForAction(null); setSelectedWorktreeForAction(null);
// 6. Force-sync settings immediately so the reset worktree // 6. Force-sync settings immediately so the reset worktree

View File

@@ -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<Props, State> {
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 (
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Board crashed</h3>
<p className="text-sm text-muted-foreground max-w-sm">
A rendering error occurred, possibly during a worktree switch. Click recover to reset
to the main branch and retry.
</p>
</div>
<Button variant="outline" size="sm" onClick={this.handleRecover} className="gap-2">
<RefreshCw className="w-4 h-4" />
Recover
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -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 (
<div className={cn('flex', className)}>
{/* Segment 1: Add Feature */}
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-r-none"
onClick={onAddFeature}
title="Add Feature"
data-testid={`${testIdPrefix}-button`}
>
<Plus className="w-3.5 h-3.5" />
</Button>
{/* Segment 2: Quick Add */}
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-none border-l border-primary-foreground/20"
onClick={onQuickAdd}
title="Quick Add"
data-testid={`${testIdPrefix}-quick-add-button`}
>
<Zap className="w-3 h-3" />
</Button>
{/* Segment 3: Templates dropdown */}
{enabledTemplates.length > 0 && (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="h-6 w-4 p-0 rounded-l-none border-l border-primary-foreground/20"
title="Templates"
data-testid={`${testIdPrefix}-dropdown-trigger`}
>
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
{enabledTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => handleTemplateClick(template)}
data-testid={`template-menu-item-${template.id}`}
>
<FileText className="w-4 h-4 mr-2" />
<span className="truncate max-w-[200px]">{template.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
// Full mode: Three-segment button
return (
<div className={cn('flex justify-center', fullWidth && 'w-full', className)}>
{/* Segment 1: Add Feature */}
<Button
variant="default"
size="sm"
className={cn('h-8 text-xs px-3 rounded-r-none', fullWidth && 'flex-1')}
onClick={onAddFeature}
data-testid={`${testIdPrefix}-button`}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
Add Feature
{shortcut && (
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1 py-0.5 rounded">
{shortcut}
</span>
)}
</Button>
{/* Segment 2: Quick Add */}
<Button
variant="default"
size="sm"
className={cn(
'h-8 text-xs px-2.5 rounded-none border-l border-primary-foreground/20',
fullWidth && 'flex-shrink-0'
)}
onClick={onQuickAdd}
data-testid={`${testIdPrefix}-quick-add-button`}
>
<Zap className="w-3.5 h-3.5 mr-1" />
Quick
</Button>
{/* Segment 3: Templates dropdown */}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className={cn(
'h-8 rounded-l-none border-l border-primary-foreground/20',
enabledTemplates.length > 0 ? 'px-1.5' : 'w-7 p-0',
fullWidth && 'flex-shrink-0'
)}
aria-label="Templates"
title="Templates"
data-testid={`${testIdPrefix}-dropdown-trigger`}
>
<FileText className="w-3.5 h-3.5 mr-0.5" />
<ChevronDown className="w-2.5 h-2.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{enabledTemplates.length > 0 ? (
enabledTemplates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => handleTemplateClick(template)}
data-testid={`template-menu-item-${template.id}`}
>
<FileText className="w-4 h-4 mr-2" />
<span className="truncate max-w-[200px]">{template.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled className="text-muted-foreground">
No templates configured
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -5,12 +5,13 @@ import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useAppStore, formatShortcut } from '@/store/app-store'; import { useAppStore, formatShortcut } from '@/store/app-store';
import type { Feature } 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 { ListHeader } from './list-header';
import { ListRow, sortFeatures } from './list-row'; import { ListRow, sortFeatures } from './list-row';
import { createRowActionHandlers, type RowActionHandlers } from './row-actions'; import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
import { getStatusOrder } from './status-badge'; import { getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants'; import { getColumnsWithPipeline } from '../../constants';
import { AddFeatureButton } from '../add-feature-button';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state'; import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */ /** Empty set constant to avoid creating new instances on each render */
@@ -65,6 +66,12 @@ export interface ListViewProps {
pipelineConfig?: PipelineConfig | null; pipelineConfig?: PipelineConfig | null;
/** Callback to add a new feature */ /** Callback to add a new feature */
onAddFeature?: () => void; 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 */ /** Whether selection mode is enabled */
isSelectionMode?: boolean; isSelectionMode?: boolean;
/** Set of selected feature IDs */ /** Set of selected feature IDs */
@@ -125,7 +132,22 @@ const StatusGroupHeader = memo(function StatusGroupHeader({
/** /**
* EmptyState displays a message when there are no features * 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 ( return (
<div <div
className={cn( className={cn(
@@ -135,12 +157,21 @@ const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: (
data-testid="list-view-empty" data-testid="list-view-empty"
> >
<p className="text-sm mb-4">No features to display</p> <p className="text-sm mb-4">No features to display</p>
{onAddFeature && ( {canShowSplitButton ? (
<AddFeatureButton
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates || []}
shortcut={shortcut}
testIdPrefix="list-view-empty-add-feature"
/>
) : onAddFeature ? (
<Button variant="default" size="sm" onClick={onAddFeature}> <Button variant="default" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Add Feature Add Feature
</Button> </Button>
)} ) : null}
</div> </div>
); );
}); });
@@ -190,6 +221,9 @@ export const ListView = memo(function ListView({
runningAutoTasks, runningAutoTasks,
pipelineConfig = null, pipelineConfig = null,
onAddFeature, onAddFeature,
onQuickAdd,
onTemplateSelect,
templates = [],
isSelectionMode = false, isSelectionMode = false,
selectedFeatureIds = EMPTY_SET, selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection, onToggleFeatureSelection,
@@ -388,7 +422,13 @@ export const ListView = memo(function ListView({
if (totalFeatures === 0) { if (totalFeatures === 0) {
return ( return (
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view"> <div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
<EmptyState onAddFeature={onAddFeature} /> <EmptyState
onAddFeature={onAddFeature}
onQuickAdd={onQuickAdd}
onTemplateSelect={onTemplateSelect}
templates={templates}
shortcut={formatShortcut(addFeatureShortcut, true)}
/>
</div> </div>
); );
} }
@@ -452,21 +492,17 @@ export const ListView = memo(function ListView({
</div> </div>
{/* Footer with Add Feature button, styled like board view */} {/* Footer with Add Feature button, styled like board view */}
{onAddFeature && ( {onAddFeature && onQuickAdd && onTemplateSelect && (
<div className="border-t border-border px-4 py-2"> <div className="border-t border-border px-4 py-2">
<Button <AddFeatureButton
variant="default" onAddFeature={onAddFeature}
size="sm" onQuickAdd={onQuickAdd}
onClick={onAddFeature} onTemplateSelect={onTemplateSelect}
className="w-full h-9 text-sm" templates={templates}
data-testid="list-view-add-feature" fullWidth
> shortcut={formatShortcut(addFeatureShortcut, true)}
<Plus className="w-4 h-4 mr-2" /> testIdPrefix="list-view-add-feature"
Add Feature />
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -29,7 +29,10 @@ import { useAppStore } from '@/store/app-store';
/** /**
* Normalize PhaseModelEntry or string to PhaseModelEntry * 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') { if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId }; 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) // Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel); const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model; 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) { if (!result.success) {
logger.error('Backlog plan generation failed to start', { logger.error('Backlog plan generation failed to start', {
error: result.error, error: result.error,
@@ -131,7 +139,15 @@ export function BacklogPlanDialog({
}); });
setPrompt(''); setPrompt('');
onClose(); onClose();
}, [projectPath, prompt, modelOverride, phaseModels, setIsGeneratingPlan, onClose]); }, [
projectPath,
prompt,
modelOverride,
phaseModels,
setIsGeneratingPlan,
onClose,
currentBranch,
]);
const handleApply = useCallback(async () => { const handleApply = useCallback(async () => {
if (!pendingPlanResult) return; if (!pendingPlanResult) return;

View File

@@ -10,12 +10,27 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { toast } from 'sonner'; import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/** /**
* Parse git/worktree error messages and return user-friendly versions * 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. // allow free-form branch entry via allowCreate as a fallback.
const [branchFetchError, setBranchFetchError] = useState<string | null>(null); const [branchFetchError, setBranchFetchError] = useState<string | null>(null);
// Remote selection state
const [selectedRemote, setSelectedRemote] = useState<string>('local');
const [availableRemotes, setAvailableRemotes] = useState<Array<{ name: string; url: string }>>(
[]
);
const [remoteBranches, setRemoteBranches] = useState<
Map<string, Array<{ name: string; fullRef: string }>>
>(new Map());
// AbortController ref so in-flight branch fetches can be cancelled when the dialog closes // AbortController ref so in-flight branch fetches can be cancelled when the dialog closes
const branchFetchAbortRef = useRef<AbortController | null>(null); const branchFetchAbortRef = useRef<AbortController | null>(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( const fetchBranches = useCallback(
async (signal?: AbortSignal) => { async (signal?: AbortSignal) => {
if (!projectPath) return; if (!projectPath) return;
@@ -125,13 +149,16 @@ export function CreateWorktreeDialog({
try { try {
const api = getHttpApiClient(); const api = getHttpApiClient();
// Fetch branches using the project path (use listBranches on the project root). // Fetch both branches and remotes in parallel
// Pass the AbortSignal so controller.abort() cancels the in-flight HTTP request. const [branchResult, remotesResult] = await Promise.all([
const branchResult = await api.worktree.listBranches(projectPath, true, signal); 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 the fetch was aborted while awaiting, bail out to avoid stale state writes
if (signal?.aborted) return; if (signal?.aborted) return;
// Process branches
if (branchResult.success && branchResult.result) { if (branchResult.success && branchResult.result) {
setBranchFetchError(null); setBranchFetchError(null);
setAvailableBranches( setAvailableBranches(
@@ -147,6 +174,30 @@ export function CreateWorktreeDialog({
setBranchFetchError(message); setBranchFetchError(message);
setAvailableBranches([{ name: 'main', isRemote: false }]); 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<string, Array<{ name: string; fullRef: string }>>();
remotes.forEach(
(r: {
name: string;
url: string;
branches: Array<{ name: string; fullRef: string }>;
}) => {
branchesMap.set(r.name, r.branches || []);
}
);
setRemoteBranches(branchesMap);
}
} catch (err) { } catch (err) {
// If aborted, don't update state // If aborted, don't update state
if (signal?.aborted) return; if (signal?.aborted) return;
@@ -160,6 +211,8 @@ export function CreateWorktreeDialog({
// and enable free-form entry (allowCreate) so the user can still type // and enable free-form entry (allowCreate) so the user can still type
// any branch name when the remote list is unavailable. // any branch name when the remote list is unavailable.
setAvailableBranches([{ name: 'main', isRemote: false }]); setAvailableBranches([{ name: 'main', isRemote: false }]);
setAvailableRemotes([]);
setRemoteBranches(new Map());
} finally { } finally {
if (!signal?.aborted) { if (!signal?.aborted) {
setIsLoadingBranches(false); setIsLoadingBranches(false);
@@ -198,27 +251,30 @@ export function CreateWorktreeDialog({
setAvailableBranches([]); setAvailableBranches([]);
setBranchFetchError(null); setBranchFetchError(null);
setIsLoadingBranches(false); setIsLoadingBranches(false);
setSelectedRemote('local');
setAvailableRemotes([]);
setRemoteBranches(new Map());
} }
}, [open]); }, [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 branchNames = useMemo(() => {
const local: string[] = []; // If "local" is selected, show only local branches
const remote: string[] = []; if (selectedRemote === 'local') {
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
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);
}
} }
// Local branches first, then remote branches // If a specific remote is selected, show only branches from that remote
return [...local, ...remote]; const remoteBranchList = remoteBranches.get(selectedRemote);
}, [availableBranches]); 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. // Determine if the selected base branch is a remote branch.
// Also detect manually entered remote-style names (e.g. "origin/feature") // Also detect manually entered remote-style names (e.g. "origin/feature")
@@ -418,6 +474,47 @@ export function CreateWorktreeDialog({
</div> </div>
)} )}
{/* Remote Selector */}
<div className="grid gap-1.5">
<Label htmlFor="remote-select" className="text-xs text-muted-foreground">
Source
</Label>
<Select
value={selectedRemote}
onValueChange={(value) => {
setSelectedRemote(value);
// Clear base branch when switching remotes
setBaseBranch('');
}}
disabled={isLoadingBranches}
>
<SelectTrigger id="remote-select" className="h-8">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5" />
<span>Local Branches</span>
</div>
</SelectItem>
{availableRemotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex items-center gap-2">
<Cloud className="w-3.5 h-3.5" />
<span>{remote.name}</span>
{remote.url && (
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
({remote.url})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<BranchAutocomplete <BranchAutocomplete
value={baseBranch} value={baseBranch}
onChange={(value) => { onChange={(value) => {
@@ -425,9 +522,13 @@ export function CreateWorktreeDialog({
setError(null); setError(null);
}} }}
branches={branchNames} branches={branchNames}
placeholder="Select base branch (default: HEAD)..." placeholder={
selectedRemote === 'local'
? 'Select local branch (default: HEAD)...'
: `Select branch from ${selectedRemote}...`
}
disabled={isLoadingBranches} disabled={isLoadingBranches}
allowCreate={!!branchFetchError} allowCreate={!!branchFetchError || selectedRemote === 'local'}
/> />
{isRemoteBaseBranch && ( {isRemoteBaseBranch && (

View File

@@ -1,4 +1,5 @@
export { AddFeatureDialog } from './add-feature-dialog'; export { AddFeatureDialog } from './add-feature-dialog';
export { QuickAddDialog } from './quick-add-dialog';
export { AgentOutputModal } from './agent-output-modal'; export { AgentOutputModal } from './agent-output-modal';
export { BacklogPlanDialog } from './backlog-plan-dialog'; export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal'; export { CompletedFeaturesModal } from './completed-features-modal';

View File

@@ -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<HTMLTextAreaElement>(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<PhaseModelEntry>(
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
compact
className="sm:max-w-md"
data-testid="quick-add-dialog"
onOpenAutoFocus={(e) => {
e.preventDefault();
textareaRef.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>Quick Add Feature</DialogTitle>
<DialogDescription>
Create a new feature with minimal configuration. All other settings use defaults.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Description Input */}
<div className="space-y-2">
<label htmlFor="quick-add-description" className="text-sm font-medium">
Description
</label>
<Textarea
ref={textareaRef}
id="quick-add-description"
value={description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what you want to build..."
className={
descriptionError ? 'border-destructive focus-visible:ring-destructive' : ''
}
rows={3}
data-testid="quick-add-description-input"
/>
{descriptionError && (
<p className="text-xs text-destructive">Description is required</p>
)}
</div>
{/* Model Selection */}
<PhaseModelSelector value={modelEntry} onChange={setModelEntry} compact align="end" />
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="secondary" onClick={handleAdd} data-testid="quick-add-button">
<Plus className="w-4 h-4 mr-2" />
Add
</Button>
<HotkeyButton
onClick={handleAddAndStart}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="quick-add-and-start-button"
>
<Play className="w-4 h-4 mr-2" />
Make
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,11 +13,20 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components'; import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store'; import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react'; import {
Archive,
Settings2,
CheckSquare,
GripVertical,
Plus,
CheckCircle2,
Zap,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants'; import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types'; import type { PipelineConfig, FeatureTemplate } from '@automaker/types';
import { AddFeatureButton } from './components/add-feature-button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface KanbanBoardProps { interface KanbanBoardProps {
activeFeature: Feature | null; activeFeature: Feature | null;
@@ -53,6 +62,10 @@ interface KanbanBoardProps {
runningAutoTasks: string[]; runningAutoTasks: string[];
onArchiveAllVerified: () => void; onArchiveAllVerified: () => void;
onAddFeature: () => void; onAddFeature: () => void;
onQuickAdd: () => void;
onTemplateSelect: (template: FeatureTemplate) => void;
templates: FeatureTemplate[];
addFeatureShortcut?: string;
onShowCompletedModal: () => void; onShowCompletedModal: () => void;
completedCount: number; completedCount: number;
pipelineConfig: PipelineConfig | null; pipelineConfig: PipelineConfig | null;
@@ -292,6 +305,10 @@ export function KanbanBoard({
runningAutoTasks, runningAutoTasks,
onArchiveAllVerified, onArchiveAllVerified,
onAddFeature, onAddFeature,
onQuickAdd,
onTemplateSelect,
templates,
addFeatureShortcut: addFeatureShortcutProp,
onShowCompletedModal, onShowCompletedModal,
completedCount, completedCount,
pipelineConfig, pipelineConfig,
@@ -311,7 +328,7 @@ export function KanbanBoard({
// Get the keyboard shortcut for adding features // Get the keyboard shortcut for adding features
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; const addFeatureShortcut = addFeatureShortcutProp || keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size // Use responsive column widths based on window size
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron // containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
@@ -408,16 +425,28 @@ export function KanbanBoard({
</div> </div>
) : column.id === 'backlog' ? ( ) : column.id === 'backlog' ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <div className="flex items-center">
variant="default" <Button
size="sm" variant="default"
className="h-6 w-6 p-0" size="sm"
onClick={onAddFeature} className="h-6 w-6 p-0 rounded-r-none"
title="Add Feature" onClick={onAddFeature}
data-testid="add-feature-button" title="Add Feature"
> data-testid="add-feature-button"
<Plus className="w-3.5 h-3.5" /> >
</Button> <Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0 rounded-l-none border-l border-primary-foreground/20"
onClick={onQuickAdd}
title="Quick Add Feature"
data-testid="quick-add-feature-button"
>
<Zap className="w-3.5 h-3.5" />
</Button>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -494,19 +523,14 @@ export function KanbanBoard({
} }
footerAction={ footerAction={
column.id === 'backlog' ? ( column.id === 'backlog' ? (
<Button <AddFeatureButton
variant="default" onAddFeature={onAddFeature}
size="sm" onQuickAdd={onQuickAdd}
className="w-full h-9 text-sm" onTemplateSelect={onTemplateSelect}
onClick={onAddFeature} templates={templates}
data-testid="add-feature-floating-button" fullWidth
> shortcut={formatShortcut(addFeatureShortcut, true)}
<Plus className="w-4 h-4 mr-2" /> />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined ) : undefined
} }
> >

View File

@@ -147,6 +147,8 @@ interface WorktreeActionsDropdownProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void; onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */ /** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void; onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
} }
/** /**
@@ -182,7 +184,9 @@ function RemoteActionMenuItem({
<Icon className="w-3.5 h-3.5 mr-2" /> <Icon className="w-3.5 h-3.5 mr-2" />
{remote.name} {remote.name}
{trackingRemote === remote.name && ( {trackingRemote === remote.name && (
<span className="ml-auto text-[10px] text-muted-foreground mr-1">tracking</span> <span className="ml-auto text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded mr-2">
tracking
</span>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
@@ -282,6 +286,7 @@ export function WorktreeActionsDropdown({
onSync, onSync,
onSyncWithRemote, onSyncWithRemote,
onSetTracking, onSetTracking,
remotesWithBranch,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu // Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors(); const { editors } = useAvailableEditors();
@@ -326,6 +331,21 @@ export function WorktreeActionsDropdown({
? 'Repository has no commits yet' ? 'Repository has no commits yet'
: null; : null;
// Check if the branch exists on remotes other than the tracking remote.
// This indicates the branch was pushed to a different remote than the one being tracked,
// so the ahead/behind counts may be misleading.
const otherRemotesWithBranch = useMemo(() => {
if (!remotesWithBranch || remotesWithBranch.length === 0) return [];
if (!trackingRemote) return remotesWithBranch;
return remotesWithBranch.filter((r) => r !== trackingRemote);
}, [remotesWithBranch, trackingRemote]);
// True when branch exists on a different remote but NOT on the tracking remote
const isOnDifferentRemote =
otherRemotesWithBranch.length > 0 &&
trackingRemote &&
!remotesWithBranch?.includes(trackingRemote);
// Determine if the changes/PR section has any visible items // Determine if the changes/PR section has any visible items
// Show Create PR when no existing PR is linked // Show Create PR when no existing PR is linked
const showCreatePR = !hasPR; const showCreatePR = !hasPR;
@@ -783,11 +803,17 @@ export function WorktreeActionsDropdown({
{!isGitOpsAvailable && ( {!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" /> <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)} )}
{isGitOpsAvailable && behindCount > 0 && ( {isGitOpsAvailable && !isOnDifferentRemote && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded"> <span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind {behindCount} behind
</span> </span>
)} )}
{isGitOpsAvailable && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
className={cn( className={cn(
@@ -832,11 +858,17 @@ export function WorktreeActionsDropdown({
{!isGitOpsAvailable && ( {!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" /> <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)} )}
{isGitOpsAvailable && behindCount > 0 && ( {isGitOpsAvailable && !isOnDifferentRemote && behindCount > 0 && (
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded"> <span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
{behindCount} behind {behindCount} behind
</span> </span>
)} )}
{isGitOpsAvailable && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</TooltipWrapper> </TooltipWrapper>
@@ -856,7 +888,9 @@ export function WorktreeActionsDropdown({
} }
}} }}
disabled={ disabled={
isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable isPushing ||
(hasRemoteBranch && !isOnDifferentRemote && aheadCount === 0) ||
!isGitOpsAvailable
} }
className={cn( className={cn(
'text-xs flex-1 pr-0 rounded-r-none', 'text-xs flex-1 pr-0 rounded-r-none',
@@ -874,21 +908,33 @@ export function WorktreeActionsDropdown({
local only local only
</span> </span>
)} )}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && ( {isGitOpsAvailable && hasRemoteBranch && isOnDifferentRemote && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded"> <span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
{aheadCount} ahead <Globe className="w-2.5 h-2.5" />
</span> on {otherRemotesWithBranch.join(', ')}
)}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span> </span>
)} )}
{isGitOpsAvailable &&
hasRemoteBranch &&
!isOnDifferentRemote &&
aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead
</span>
)}
{isGitOpsAvailable &&
hasRemoteBranch &&
!isOnDifferentRemote &&
trackingRemote && (
<span
className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',
aheadCount > 0 ? 'ml-1' : 'ml-auto'
)}
>
{trackingRemote}
</span>
)}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
className={cn( className={cn(
@@ -932,7 +978,11 @@ export function WorktreeActionsDropdown({
onPush(worktree); onPush(worktree);
} }
}} }}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !isGitOpsAvailable} disabled={
isPushing ||
(hasRemoteBranch && !isOnDifferentRemote && aheadCount === 0) ||
!isGitOpsAvailable
}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
> >
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} /> <Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
@@ -946,12 +996,18 @@ export function WorktreeActionsDropdown({
local only local only
</span> </span>
)} )}
{isGitOpsAvailable && hasRemoteBranch && aheadCount > 0 && ( {isGitOpsAvailable && hasRemoteBranch && isOnDifferentRemote && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
<Globe className="w-2.5 h-2.5" />
on {otherRemotesWithBranch.join(', ')}
</span>
)}
{isGitOpsAvailable && hasRemoteBranch && !isOnDifferentRemote && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded"> <span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead {aheadCount} ahead
</span> </span>
)} )}
{isGitOpsAvailable && hasRemoteBranch && trackingRemote && ( {isGitOpsAvailable && hasRemoteBranch && !isOnDifferentRemote && trackingRemote && (
<span <span
className={cn( className={cn(
'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded', 'text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded',

View File

@@ -146,6 +146,8 @@ export interface WorktreeDropdownProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void; onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */ /** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void; onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
} }
/** /**
@@ -242,6 +244,7 @@ export function WorktreeDropdown({
onSync, onSync,
onSyncWithRemote, onSyncWithRemote,
onSetTracking, onSetTracking,
remotesWithBranch,
}: WorktreeDropdownProps) { }: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger // Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -565,6 +568,7 @@ export function WorktreeDropdown({
onSync={onSync} onSync={onSync}
onSyncWithRemote={onSyncWithRemote} onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking} onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
/> />
)} )}
</div> </div>

View File

@@ -116,6 +116,8 @@ interface WorktreeTabProps {
onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void; onSyncWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Set tracking branch to a specific remote */ /** Set tracking branch to a specific remote */
onSetTracking?: (worktree: WorktreeInfo, remote: string) => void; onSetTracking?: (worktree: WorktreeInfo, remote: string) => void;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
} }
export function WorktreeTab({ export function WorktreeTab({
@@ -193,6 +195,7 @@ export function WorktreeTab({
onSync, onSync,
onSyncWithRemote, onSyncWithRemote,
onSetTracking, onSetTracking,
remotesWithBranch,
}: WorktreeTabProps) { }: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards // Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
@@ -566,6 +569,7 @@ export function WorktreeTab({
onSync={onSync} onSync={onSync}
onSyncWithRemote={onSyncWithRemote} onSyncWithRemote={onSyncWithRemote}
onSetTracking={onSetTracking} onSetTracking={onSetTracking}
remotesWithBranch={remotesWithBranch}
/> />
</div> </div>
); );

View File

@@ -17,6 +17,8 @@ export interface UseBranchesReturn {
trackingRemote: string | undefined; trackingRemote: string | undefined;
/** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */ /** Per-worktree tracking remote lookup — avoids stale values when multiple panels share the hook */
getTrackingRemote: (worktreePath: string) => string | undefined; getTrackingRemote: (worktreePath: string) => string | undefined;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch: string[];
isLoadingBranches: boolean; isLoadingBranches: boolean;
branchFilter: string; branchFilter: string;
setBranchFilter: (filter: string) => void; setBranchFilter: (filter: string) => void;
@@ -49,6 +51,7 @@ export function useBranches(): UseBranchesReturn {
const behindCount = branchData?.behindCount ?? 0; const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false; const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
const trackingRemote = branchData?.trackingRemote; const trackingRemote = branchData?.trackingRemote;
const remotesWithBranch = branchData?.remotesWithBranch ?? [];
// Per-worktree tracking remote cache: keeps results from previous fetchBranches() // Per-worktree tracking remote cache: keeps results from previous fetchBranches()
// calls so multiple WorktreePanel instances don't all share a single stale value. // calls so multiple WorktreePanel instances don't all share a single stale value.
@@ -119,6 +122,7 @@ export function useBranches(): UseBranchesReturn {
hasRemoteBranch, hasRemoteBranch,
trackingRemote, trackingRemote,
getTrackingRemote, getTrackingRemote,
remotesWithBranch,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef } from 'react'; import { useEffect, useCallback, useRef, startTransition } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useWorktrees as useWorktreesQuery } from '@/hooks/queries'; import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
@@ -93,7 +93,15 @@ export function useWorktrees({
// Fallback to "main" only if worktrees haven't loaded yet // Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain); const mainWorktree = worktrees.find((w) => w.isMain);
const mainBranch = mainWorktree?.branch || 'main'; const mainBranch = mainWorktree?.branch || 'main';
setCurrentWorktree(projectPath, null, mainBranch); // Note: Zustand uses useSyncExternalStore so setCurrentWorktree updates
// are flushed synchronously. The real guard against React error #185 is
// dependency isolation — currentWorktree is intentionally excluded from
// the validation effect deps below (via currentWorktreeRef) so we don't
// create a feedback loop. startTransition may still help batch unrelated
// React state updates but does NOT defer or prevent Zustand-driven cascades.
startTransition(() => {
setCurrentWorktree(projectPath, null, mainBranch);
});
} }
} }
}, [worktrees, projectPath, setCurrentWorktree]); }, [worktrees, projectPath, setCurrentWorktree]);
@@ -109,7 +117,16 @@ export function useWorktrees({
if (isSameWorktree) return; if (isSameWorktree) return;
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch); // Note: Zustand uses useSyncExternalStore so setCurrentWorktree updates are
// flushed synchronously — startTransition does NOT prevent Zustand-driven
// cascades. The actual protection against React error #185 is dependency
// isolation via currentWorktreeRef (currentWorktree is excluded from the
// validation effect's dependency array). startTransition may still help
// batch unrelated concurrent React state updates but should not be relied
// upon for Zustand update ordering.
startTransition(() => {
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
});
// Defer feature query invalidation so the store update and client-side // Defer feature query invalidation so the store update and client-side
// re-filtering happen in the current render cycle first. The features // re-filtering happen in the current render cycle first. The features
@@ -121,7 +138,7 @@ export function useWorktrees({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath), queryKey: queryKeys.features.all(projectPath),
}); });
}, 0); }, 100);
}, },
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath] [projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
); );

View File

@@ -101,6 +101,7 @@ export function WorktreePanel({
hasRemoteBranch, hasRemoteBranch,
trackingRemote, trackingRemote,
getTrackingRemote, getTrackingRemote,
remotesWithBranch,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,
@@ -466,20 +467,8 @@ export function WorktreePanel({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk // NOTE: Periodic polling is handled by React Query's refetchInterval
// Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh // in hooks/queries/use-worktrees.ts (30s). No separate setInterval needed.
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 30000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchWorktrees]);
// Prune stale tracking-remote cache entries and remotes cache when worktrees change // Prune stale tracking-remote cache entries and remotes cache when worktrees change
useEffect(() => { useEffect(() => {
@@ -967,6 +956,7 @@ export function WorktreePanel({
onSync={handleSyncWithRemoteSelection} onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote} onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote} onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -1214,6 +1204,7 @@ export function WorktreePanel({
onSync={handleSyncWithRemoteSelection} onSync={handleSyncWithRemoteSelection}
onSyncWithRemote={handleSyncWithSpecificRemote} onSyncWithRemote={handleSyncWithSpecificRemote}
onSetTracking={handleSetTrackingForRemote} onSetTracking={handleSetTrackingForRemote}
remotesWithBranch={remotesWithBranch}
remotesCache={remotesCache} remotesCache={remotesCache}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
@@ -1358,6 +1349,7 @@ export function WorktreePanel({
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript} onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts} onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/> />
)} )}
</div> </div>
@@ -1449,6 +1441,7 @@ export function WorktreePanel({
terminalScripts={terminalScripts} terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript} onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts} onEditScripts={handleEditScripts}
remotesWithBranch={remotesWithBranch}
/> />
); );
})} })}

View File

@@ -30,6 +30,7 @@ import {
import { MCPServersSection } from './settings-view/mcp-servers'; import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts'; import { PromptCustomizationSection } from './settings-view/prompts';
import { EventHooksSection } from './settings-view/event-hooks'; import { EventHooksSection } from './settings-view/event-hooks';
import { TemplatesSection } from './settings-view/templates/templates-section';
import { ImportExportDialog } from './settings-view/components/import-export-dialog'; import { ImportExportDialog } from './settings-view/components/import-export-dialog';
import type { Theme } from './settings-view/shared/types'; import type { Theme } from './settings-view/shared/types';
@@ -65,6 +66,12 @@ export function SettingsView() {
setSkipSandboxWarning, setSkipSandboxWarning,
defaultMaxTurns, defaultMaxTurns,
setDefaultMaxTurns, setDefaultMaxTurns,
featureTemplates,
setFeatureTemplates,
addFeatureTemplate,
updateFeatureTemplate,
deleteFeatureTemplate,
reorderFeatureTemplates,
} = useAppStore(); } = useAppStore();
// Global theme (project-specific themes are managed in Project Settings) // Global theme (project-specific themes are managed in Project Settings)
@@ -142,6 +149,16 @@ export function SettingsView() {
onPromptCustomizationChange={setPromptCustomization} onPromptCustomizationChange={setPromptCustomization}
/> />
); );
case 'templates':
return (
<TemplatesSection
templates={featureTemplates}
onAddTemplate={addFeatureTemplate}
onUpdateTemplate={updateFeatureTemplate}
onDeleteTemplate={deleteFeatureTemplate}
onReorderTemplates={reorderFeatureTemplates}
/>
);
case 'model-defaults': case 'model-defaults':
return <ModelDefaultsSection />; return <ModelDefaultsSection />;
case 'appearance': case 'appearance':

View File

@@ -1,17 +1,20 @@
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { FileCode } from 'lucide-react'; import { FileCode, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ClaudeMdSettingsProps { interface ClaudeMdSettingsProps {
autoLoadClaudeMd: boolean; autoLoadClaudeMd: boolean;
onAutoLoadClaudeMdChange: (enabled: boolean) => void; onAutoLoadClaudeMdChange: (enabled: boolean) => void;
useClaudeCodeSystemPrompt: boolean;
onUseClaudeCodeSystemPromptChange: (enabled: boolean) => void;
} }
/** /**
* ClaudeMdSettings Component * ClaudeMdSettings Component
* *
* UI controls for Claude Agent SDK settings including: * UI controls for Claude Agent SDK settings including:
* - Using Claude Code's built-in system prompt as the base
* - Auto-loading of project instructions from .claude/CLAUDE.md files * - Auto-loading of project instructions from .claude/CLAUDE.md files
* *
* Usage: * Usage:
@@ -19,12 +22,16 @@ interface ClaudeMdSettingsProps {
* <ClaudeMdSettings * <ClaudeMdSettings
* autoLoadClaudeMd={autoLoadClaudeMd} * autoLoadClaudeMd={autoLoadClaudeMd}
* onAutoLoadClaudeMdChange={setAutoLoadClaudeMd} * onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
* useClaudeCodeSystemPrompt={useClaudeCodeSystemPrompt}
* onUseClaudeCodeSystemPromptChange={setUseClaudeCodeSystemPrompt}
* /> * />
* ``` * ```
*/ */
export function ClaudeMdSettings({ export function ClaudeMdSettings({
autoLoadClaudeMd, autoLoadClaudeMd,
onAutoLoadClaudeMdChange, onAutoLoadClaudeMdChange,
useClaudeCodeSystemPrompt,
onUseClaudeCodeSystemPromptChange,
}: ClaudeMdSettingsProps) { }: ClaudeMdSettingsProps) {
return ( return (
<div <div
@@ -39,17 +46,38 @@ export function ClaudeMdSettings({
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent"> <div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FileCode className="w-5 h-5 text-brand-500" /> <Terminal className="w-5 h-5 text-brand-500" />
</div> </div>
<h2 className="text-lg font-semibold text-foreground tracking-tight"> <h2 className="text-lg font-semibold text-foreground tracking-tight">Claude Agent SDK</h2>
CLAUDE.md Integration
</h2>
</div> </div>
<p className="text-sm text-muted-foreground/80 ml-12"> <p className="text-sm text-muted-foreground/80 ml-12">
Configure automatic loading of project-specific instructions. Configure Claude Code system prompt and project instructions.
</p> </p>
</div> </div>
<div className="p-6"> <div className="p-6 space-y-2">
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-claude-code-system-prompt"
checked={useClaudeCodeSystemPrompt}
onCheckedChange={(checked) => onUseClaudeCodeSystemPromptChange(checked === true)}
className="mt-1"
data-testid="use-claude-code-system-prompt-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-claude-code-system-prompt"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Terminal className="w-4 h-4 text-brand-500" />
Use Claude Code System Prompt
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Use Claude Code&apos;s built-in system prompt as the base for all agent sessions.
Automaker&apos;s prompts are appended on top. When disabled, only Automaker&apos;s
custom system prompt is used.
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3"> <div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox <Checkbox
id="auto-load-claude-md" id="auto-load-claude-md"

View File

@@ -114,7 +114,7 @@ export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogPro
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col"> <DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-3xl lg:max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Import / Export Settings</DialogTitle> <DialogTitle>Import / Export Settings</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -125,7 +125,7 @@ export function ImportExportDialog({ open, onOpenChange }: ImportExportDialogPro
<div className="flex-1 flex flex-col gap-4 min-h-0 mt-4"> <div className="flex-1 flex flex-col gap-4 min-h-0 mt-4">
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"

View File

@@ -16,7 +16,7 @@ interface KeyboardMapDialogProps {
export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) { export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent className="bg-popover border-border max-w-[calc(100%-2rem)] sm:max-w-3xl lg:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Keyboard className="w-5 h-5 text-brand-500" /> <Keyboard className="w-5 h-5 text-brand-500" />

View File

@@ -17,6 +17,7 @@ import {
Code2, Code2,
Webhook, Webhook,
FileCode2, FileCode2,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AnthropicIcon, AnthropicIcon,
@@ -49,6 +50,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow }, { id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText }, { id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'templates', label: 'Templates', icon: FileText },
{ id: 'api-keys', label: 'API Keys', icon: Key }, { id: 'api-keys', label: 'API Keys', icon: Key },
{ {
id: 'providers', id: 'providers',

View File

@@ -100,8 +100,8 @@ export function FeatureDefaultsSection({
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10"> <div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<Cpu className="w-5 h-5 text-brand-500" /> <Cpu className="w-5 h-5 text-brand-500" />
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label className="text-foreground font-medium">Default Model</Label> <Label className="text-foreground font-medium">Default Model</Label>
<PhaseModelSelector <PhaseModelSelector
value={defaultFeatureModel} value={defaultFeatureModel}
@@ -124,8 +124,8 @@ export function FeatureDefaultsSection({
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-orange-500/10"> <div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-orange-500/10">
<RotateCcw className="w-5 h-5 text-orange-500" /> <RotateCcw className="w-5 h-5 text-orange-500" />
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label htmlFor="default-max-turns" className="text-foreground font-medium"> <Label htmlFor="default-max-turns" className="text-foreground font-medium">
Max Agent Turns Max Agent Turns
</Label> </Label>
@@ -187,14 +187,17 @@ export function FeatureDefaultsSection({
{defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />} {defaultPlanningMode === 'spec' && <FileText className="w-5 h-5 text-purple-500" />}
{defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />} {defaultPlanningMode === 'full' && <ScrollText className="w-5 h-5 text-amber-500" />}
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<Label className="text-foreground font-medium">Default Planning Mode</Label> <Label className="text-foreground font-medium">Default Planning Mode</Label>
<Select <Select
value={defaultPlanningMode} value={defaultPlanningMode}
onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)} onValueChange={(v: string) => onDefaultPlanningModeChange(v as PlanningMode)}
> >
<SelectTrigger className="w-[160px] h-8" data-testid="default-planning-mode-select"> <SelectTrigger
className="w-full sm:w-[160px] h-8"
data-testid="default-planning-mode-select"
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -12,6 +12,7 @@ export type SettingsViewId =
| 'copilot-provider' | 'copilot-provider'
| 'mcp-servers' | 'mcp-servers'
| 'prompts' | 'prompts'
| 'templates'
| 'model-defaults' | 'model-defaults'
| 'appearance' | 'appearance'
| 'editor' | 'editor'

View File

@@ -41,7 +41,7 @@ export function AddEditServerDialog({
}: AddEditServerDialogProps) { }: AddEditServerDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="mcp-server-dialog"> <DialogContent className="sm:max-w-lg" data-testid="mcp-server-dialog">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle> <DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -254,7 +254,7 @@ export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col"> <DialogContent className="max-w-[calc(100%-2rem)] sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Bulk Replace Models</DialogTitle> <DialogTitle>Bulk Replace Models</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Workflow, RotateCcw, Replace, Sparkles, Brain } from 'lucide-react'; import { Workflow, RotateCcw, Replace, Brain } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -135,31 +135,13 @@ function FeatureDefaultModelSection() {
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div <PhaseModelSelector
className={cn( label="Default Feature Model"
'flex items-center justify-between p-4 rounded-xl', description="Model and thinking level used when creating new feature cards"
'bg-accent/20 border border-border/30', value={defaultValue}
'hover:bg-accent/30 transition-colors' onChange={setDefaultFeatureModel}
)} align="end"
> />
<div className="flex items-center gap-3 flex-1 pr-4">
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-brand-500" />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">Default Feature Model</h4>
<p className="text-xs text-muted-foreground">
Model and thinking level used when creating new feature cards
</p>
</div>
</div>
<PhaseModelSelector
compact
value={defaultValue}
onChange={setDefaultFeatureModel}
align="end"
/>
</div>
</div> </div>
</div> </div>
); );
@@ -201,30 +183,30 @@ function DefaultThinkingLevelSection() {
{/* Default Thinking Level (Claude models) */} {/* Default Thinking Level (Claude models) */}
<div <div
className={cn( className={cn(
'flex items-center justify-between p-4 rounded-xl', 'flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 rounded-xl',
'bg-accent/20 border border-border/30', 'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors' 'hover:bg-accent/30 transition-colors'
)} )}
> >
<div className="flex items-center gap-3 flex-1 pr-4"> <div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center shrink-0">
<Brain className="w-4 h-4 text-purple-500" /> <Brain className="w-4 h-4 text-purple-500" />
</div> </div>
<div> <div className="min-w-0">
<h4 className="text-sm font-medium text-foreground">Default Thinking Level</h4> <h4 className="text-sm font-medium text-foreground">Default Thinking Level</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground truncate">
Applied to Claude models when quick-selected Applied to Claude models when quick-selected
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap justify-end"> <div className="flex items-center gap-1 flex-wrap justify-start sm:justify-end">
{THINKING_LEVEL_OPTIONS.map((option) => ( {THINKING_LEVEL_OPTIONS.map((option) => (
<button <button
key={option.id} key={option.id}
onClick={() => setDefaultThinkingLevel(option.id)} onClick={() => setDefaultThinkingLevel(option.id)}
className={cn( className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all', 'px-2 py-1 sm:px-2.5 sm:py-1.5 rounded-lg text-xs font-medium transition-all',
'border', 'border whitespace-nowrap',
defaultThinkingLevel === option.id defaultThinkingLevel === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm' ? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground' : 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
@@ -240,30 +222,30 @@ function DefaultThinkingLevelSection() {
{/* Default Reasoning Effort (Codex models) */} {/* Default Reasoning Effort (Codex models) */}
<div <div
className={cn( className={cn(
'flex items-center justify-between p-4 rounded-xl', 'flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 rounded-xl',
'bg-accent/20 border border-border/30', 'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors' 'hover:bg-accent/30 transition-colors'
)} )}
> >
<div className="flex items-center gap-3 flex-1 pr-4"> <div className="flex items-center gap-3 flex-1">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center"> <div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
<Brain className="w-4 h-4 text-blue-500" /> <Brain className="w-4 h-4 text-blue-500" />
</div> </div>
<div> <div className="min-w-0">
<h4 className="text-sm font-medium text-foreground">Default Reasoning Effort</h4> <h4 className="text-sm font-medium text-foreground">Default Reasoning Effort</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground truncate">
Applied to Codex/OpenAI models when quick-selected Applied to Codex/OpenAI models when quick-selected
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-wrap justify-end"> <div className="flex items-center gap-1 flex-wrap justify-start sm:justify-end">
{REASONING_EFFORT_LEVELS.map((option) => ( {REASONING_EFFORT_LEVELS.map((option) => (
<button <button
key={option.id} key={option.id}
onClick={() => setDefaultReasoningEffort(option.id)} onClick={() => setDefaultReasoningEffort(option.id)}
className={cn( className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all', 'px-2 py-1 sm:px-2.5 sm:py-1.5 rounded-lg text-xs font-medium transition-all',
'border', 'border whitespace-nowrap',
defaultReasoningEffort === option.id defaultReasoningEffort === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm' ? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground' : 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'

View File

@@ -2210,7 +2210,7 @@ export function PhaseModelSelector({
aria-expanded={open} aria-expanded={open}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'w-[280px] justify-between h-9 px-3 bg-background/50 border-border/50 hover:bg-background/80 hover:text-foreground', 'w-full sm:w-[280px] justify-between h-11 rounded-xl border-border px-3 bg-background/50 hover:bg-background/80 hover:text-foreground',
triggerClassName triggerClassName
)} )}
> >
@@ -2237,8 +2237,8 @@ export function PhaseModelSelector({
// The popover content (shared between both modes) // The popover content (shared between both modes)
const popoverContent = ( const popoverContent = (
<PopoverContent <PopoverContent
className="w-[320px] p-0" className="w-[min(calc(100vw-2rem),320px)] p-0"
align={align} align={isMobile ? 'start' : align}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()}
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
@@ -2431,13 +2431,13 @@ export function PhaseModelSelector({
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-between p-4 rounded-xl', 'flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 rounded-xl',
'bg-accent/20 border border-border/30', 'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors' 'hover:bg-accent/30 transition-colors'
)} )}
> >
{/* Label and Description */} {/* Label and Description */}
<div className="flex-1 pr-4"> <div className="w-full min-w-0 sm:flex-1 sm:pr-4">
<h4 className="text-sm font-medium text-foreground">{label}</h4> <h4 className="text-sm font-medium text-foreground">{label}</h4>
<p className="text-xs text-muted-foreground">{description}</p> <p className="text-xs text-muted-foreground">{description}</p>
</div> </div>

View File

@@ -12,7 +12,13 @@ import { ProviderToggle } from './provider-toggle';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
export function ClaudeSettingsTab() { export function ClaudeSettingsTab() {
const { apiKeys, autoLoadClaudeMd, setAutoLoadClaudeMd } = useAppStore(); const {
apiKeys,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
useClaudeCodeSystemPrompt,
setUseClaudeCodeSystemPrompt,
} = useAppStore();
const { claudeAuthStatus } = useSetupStore(); const { claudeAuthStatus } = useSetupStore();
// Use CLI status hook // Use CLI status hook
@@ -53,6 +59,8 @@ export function ClaudeSettingsTab() {
<ClaudeMdSettings <ClaudeMdSettings
autoLoadClaudeMd={autoLoadClaudeMd} autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd} onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
useClaudeCodeSystemPrompt={useClaudeCodeSystemPrompt}
onUseClaudeCodeSystemPromptChange={setUseClaudeCodeSystemPrompt}
/> />
{/* Skills Configuration */} {/* Skills Configuration */}

View File

@@ -0,0 +1,431 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { GripVertical, Plus, Pencil, Trash2, FileText, Lock, MoreHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { FeatureTemplate, PhaseModelEntry } from '@automaker/types';
import { PhaseModelSelector } from '../model-defaults/phase-model-selector';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface TemplatesSectionProps {
templates: FeatureTemplate[];
onAddTemplate: (template: FeatureTemplate) => Promise<void>;
onUpdateTemplate: (id: string, updates: Partial<FeatureTemplate>) => Promise<void>;
onDeleteTemplate: (id: string) => Promise<void>;
onReorderTemplates: (templateIds: string[]) => Promise<void>;
}
interface TemplateFormData {
name: string;
prompt: string;
model?: PhaseModelEntry;
}
const MAX_NAME_LENGTH = 50;
function generateId(): string {
return `template-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function SortableTemplateItem({
template,
onEdit,
onToggleEnabled,
onDelete,
}: {
template: FeatureTemplate;
onEdit: () => void;
onToggleEnabled: () => void;
onDelete: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: template.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isEnabled = template.enabled !== false;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border border-border/50 bg-card/50',
'transition-all duration-200',
isDragging && 'opacity-50 shadow-lg',
!isEnabled && 'opacity-60'
)}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground p-1"
data-testid={`template-drag-handle-${template.id}`}
>
<GripVertical className="w-4 h-4" />
</button>
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="font-medium truncate">{template.name}</span>
{template.isBuiltIn && (
<span title="Built-in template">
<Lock className="w-3 h-3 text-muted-foreground" />
</span>
)}
{!isEnabled && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
Disabled
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{template.prompt}</p>
</div>
{/* Actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit} data-testid={`template-edit-${template.id}`}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={onToggleEnabled}
data-testid={`template-toggle-${template.id}`}
>
<Checkbox checked={isEnabled} className="w-4 h-4 mr-2 pointer-events-none" />
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
{!template.isBuiltIn && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
data-testid={`template-delete-${template.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function TemplatesSection({
templates,
onAddTemplate,
onUpdateTemplate,
onDeleteTemplate,
onReorderTemplates,
}: TemplatesSectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<FeatureTemplate | null>(null);
const [formData, setFormData] = useState<TemplateFormData>({
name: '',
prompt: '',
});
const [nameError, setNameError] = useState(false);
const [promptError, setPromptError] = useState(false);
const defaultFeatureModel = useAppStore((s) => s.defaultFeatureModel);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleAddNew = () => {
setEditingTemplate(null);
setFormData({
name: '',
prompt: '',
model: undefined,
});
setNameError(false);
setPromptError(false);
setDialogOpen(true);
};
const handleEdit = (template: FeatureTemplate) => {
setEditingTemplate(template);
setFormData({
name: template.name,
prompt: template.prompt,
model: template.model,
});
setNameError(false);
setPromptError(false);
setDialogOpen(true);
};
const handleToggleEnabled = async (template: FeatureTemplate) => {
await onUpdateTemplate(template.id, { enabled: template.enabled === false ? true : false });
};
const handleDelete = async (template: FeatureTemplate) => {
if (template.isBuiltIn) {
toast.error('Built-in templates cannot be deleted');
return;
}
await onDeleteTemplate(template.id);
toast.success('Template deleted');
};
const handleSave = async () => {
// Validate
let hasError = false;
if (!formData.name.trim()) {
setNameError(true);
hasError = true;
}
if (!formData.prompt.trim()) {
setPromptError(true);
hasError = true;
}
if (hasError) return;
if (editingTemplate) {
// Update existing
await onUpdateTemplate(editingTemplate.id, {
name: formData.name.trim(),
prompt: formData.prompt.trim(),
model: formData.model,
});
toast.success('Template updated');
} else {
// Create new
const newTemplate: FeatureTemplate = {
id: generateId(),
name: formData.name.trim(),
prompt: formData.prompt.trim(),
model: formData.model,
isBuiltIn: false,
enabled: true,
order: Math.max(...templates.map((t) => t.order ?? 0), -1) + 1,
};
await onAddTemplate(newTemplate);
toast.success('Template created');
}
setDialogOpen(false);
};
// Memoized sorted copy — avoids mutating the Zustand-managed templates array
const sortedTemplates = useMemo(
() => [...templates].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
[templates]
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = sortedTemplates.findIndex((t) => t.id === active.id);
const newIndex = sortedTemplates.findIndex((t) => t.id === over.id);
const reordered = arrayMove(sortedTemplates, oldIndex, newIndex);
onReorderTemplates(reordered.map((t) => t.id));
}
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FileText className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Feature Templates
</h2>
</div>
<Button
variant="default"
size="sm"
onClick={handleAddNew}
data-testid="add-template-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Template
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Create reusable task templates for quick feature creation from the Add Feature dropdown.
</p>
</div>
<div className="p-6">
{templates.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No templates yet</p>
<p className="text-xs mt-1">Create your first template to get started</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedTemplates.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedTemplates.map((template) => (
<SortableTemplateItem
key={template.id}
template={template}
onEdit={() => handleEdit(template)}
onToggleEnabled={() => handleToggleEnabled(template)}
onDelete={() => handleDelete(template)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg" data-testid="template-dialog">
<DialogHeader>
<DialogTitle>{editingTemplate ? 'Edit Template' : 'Create Template'}</DialogTitle>
<DialogDescription>
{editingTemplate
? 'Update the template details below.'
: 'Create a new template for quick feature creation.'}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="template-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="template-name"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
if (e.target.value.trim()) setNameError(false);
}}
placeholder="e.g., Run tests and fix issues"
maxLength={MAX_NAME_LENGTH}
className={nameError ? 'border-destructive' : ''}
data-testid="template-name-input"
/>
<div className="flex justify-between text-xs text-muted-foreground">
{nameError && <span className="text-destructive">Name is required</span>}
<span className="ml-auto">
{formData.name.length}/{MAX_NAME_LENGTH}
</span>
</div>
</div>
{/* Prompt */}
<div className="space-y-2">
<Label htmlFor="template-prompt">
Prompt <span className="text-destructive">*</span>
</Label>
<Textarea
id="template-prompt"
value={formData.prompt}
onChange={(e) => {
setFormData({ ...formData, prompt: e.target.value });
if (e.target.value.trim()) setPromptError(false);
}}
placeholder="Describe the task the AI should perform..."
rows={4}
className={promptError ? 'border-destructive' : ''}
data-testid="template-prompt-input"
/>
{promptError && <p className="text-xs text-destructive">Prompt is required</p>}
</div>
{/* Model (optional) */}
<div className="space-y-2">
<Label htmlFor="template-model">Preferred Model (optional)</Label>
<PhaseModelSelector
value={formData.model ?? defaultFeatureModel}
onChange={(entry) => setFormData({ ...formData, model: entry })}
compact
align="end"
/>
<p className="text-xs text-muted-foreground">
If set, this model will be pre-selected when using this template.
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} data-testid="template-save-button">
{editingTemplate ? 'Save Changes' : 'Create Template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -188,6 +188,8 @@ interface BranchesResult {
hasCommits: boolean; hasCommits: boolean;
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */ /** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
trackingRemote?: string; trackingRemote?: string;
/** List of remote names that have a branch matching the current branch name */
remotesWithBranch?: string[];
} }
/** /**
@@ -246,6 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
isGitRepo: true, isGitRepo: true,
hasCommits: true, hasCommits: true,
trackingRemote: result.result?.trackingRemote, trackingRemote: result.result?.trackingRemote,
remotesWithBranch: result.result?.remotesWithBranch,
}; };
}, },
enabled: !!worktreePath, enabled: !!worktreePath,

View File

@@ -120,6 +120,17 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return worktreeIsMain ? null : worktreeBranch || null; return worktreeIsMain ? null : worktreeBranch || null;
}, [hasWorktree, worktreeIsMain, worktreeBranch]); }, [hasWorktree, worktreeIsMain, worktreeBranch]);
// Use a ref for branchName inside refreshStatus to prevent the callback identity
// from changing on every worktree switch. Without this, switching worktrees causes:
// branchName changes → refreshStatus identity changes → useEffect fires →
// API call → setAutoModeRunning → store update → re-render cascade → React error #185
// On mobile Safari/PWA this cascade is especially problematic as it triggers
// "A problem repeatedly occurred" crash loops.
const branchNameRef = useRef(branchName);
useEffect(() => {
branchNameRef.current = branchName;
}, [branchName]);
// Helper to look up project ID from path // Helper to look up project ID from path
const getProjectIdFromPath = useCallback( const getProjectIdFromPath = useCallback(
(path: string): string | undefined => { (path: string): string | undefined => {
@@ -199,6 +210,11 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}; };
}, []); }, []);
// refreshStatus uses branchNameRef instead of branchName in its dependency array
// to keep a stable callback identity across worktree switches. This prevents the
// useEffect([refreshStatus]) from re-firing on every worktree change, which on
// mobile Safari/PWA causes a cascading re-render that triggers "A problem
// repeatedly occurred" crash loops.
const refreshStatus = useCallback(async () => { const refreshStatus = useCallback(async () => {
if (!currentProject) return; if (!currentProject) return;
@@ -206,11 +222,15 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// refreshStatus runs before the API call completes and overwrites optimistic state // refreshStatus runs before the API call completes and overwrites optimistic state
if (isTransitioningRef.current) return; if (isTransitioningRef.current) return;
// Read branchName from ref to always use the latest value without
// adding it to the dependency array (which would destabilize the callback).
const currentBranchName = branchNameRef.current;
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.autoMode?.status) return; if (!api?.autoMode?.status) return;
const result = await api.autoMode.status(currentProject.path, branchName); const result = await api.autoMode.status(currentProject.path, currentBranchName);
if (result.success && result.isAutoLoopRunning !== undefined) { if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning; const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? []; const backendRunningFeatures = result.runningFeatures ?? [];
@@ -231,7 +251,9 @@ export function useAutoMode(worktree?: WorktreeInfo) {
backendRunningFeatures.length === 0); backendRunningFeatures.length === 0);
if (needsSync) { if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; const worktreeDesc = currentBranchName
? `worktree ${currentBranchName}`
: 'main worktree';
if (backendIsRunning !== currentIsRunning) { if (backendIsRunning !== currentIsRunning) {
logger.info( logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
@@ -239,18 +261,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} }
setAutoModeRunning( setAutoModeRunning(
currentProject.id, currentProject.id,
branchName, currentBranchName,
backendIsRunning, backendIsRunning,
result.maxConcurrency, result.maxConcurrency,
backendRunningFeatures backendRunningFeatures
); );
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); setAutoModeSessionForWorktree(currentProject.path, currentBranchName, backendIsRunning);
} }
} }
} catch (error) { } catch (error) {
logger.error('Error syncing auto mode state with backend:', error); logger.error('Error syncing auto mode state with backend:', error);
} }
}, [branchName, currentProject, setAutoModeRunning]); }, [currentProject, setAutoModeRunning]);
// On mount (and when refreshStatus identity changes, e.g. project switch), // On mount (and when refreshStatus identity changes, e.g. project switch),
// query backend for current auto loop status and sync UI state. // query backend for current auto loop status and sync UI state.
@@ -267,6 +289,18 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [refreshStatus]); }, [refreshStatus]);
// When the user switches worktrees, re-sync auto mode status for the new branch.
// Uses a longer debounce (300ms) than the mount effect (150ms) to let the worktree
// switch settle (store update, feature re-filtering, query invalidation) before
// triggering another API call. Without this delay, on mobile Safari the cascade of
// store mutations from the worktree switch + refreshStatus response overwhelms React's
// batching, causing "A problem repeatedly occurred" crash loops.
useEffect(() => {
const timer = setTimeout(() => void refreshStatus(), 300);
return () => clearTimeout(timer);
// branchName is the trigger; refreshStatus is stable (uses ref internally)
}, [branchName, refreshStatus]);
// Periodic polling fallback when WebSocket events are stale. // Periodic polling fallback when WebSocket events are stale.
useEffect(() => { useEffect(() => {
if (!currentProject) return; if (!currentProject) return;

View File

@@ -32,6 +32,7 @@ import { useSetupStore } from '@/store/setup-store';
import { import {
DEFAULT_OPENCODE_MODEL, DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY,
DEFAULT_PHASE_MODELS,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllCursorModelIds, getAllCursorModelIds,
migrateCursorModelIds, migrateCursorModelIds,
@@ -184,6 +185,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'], state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'], disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean, autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
useClaudeCodeSystemPrompt: state.useClaudeCodeSystemPrompt as boolean,
codexAutoLoadAgents: state.codexAutoLoadAgents as GlobalSettings['codexAutoLoadAgents'], codexAutoLoadAgents: state.codexAutoLoadAgents as GlobalSettings['codexAutoLoadAgents'],
codexSandboxMode: state.codexSandboxMode as GlobalSettings['codexSandboxMode'], codexSandboxMode: state.codexSandboxMode as GlobalSettings['codexSandboxMode'],
codexApprovalPolicy: state.codexApprovalPolicy as GlobalSettings['codexApprovalPolicy'], codexApprovalPolicy: state.codexApprovalPolicy as GlobalSettings['codexApprovalPolicy'],
@@ -756,7 +758,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
showQueryDevtools: settings.showQueryDevtools ?? true, showQueryDevtools: settings.showQueryDevtools ?? true,
enhancementModel: settings.enhancementModel ?? 'claude-sonnet', enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus', validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: settings.phaseModels ?? current.phaseModels, phaseModels: { ...DEFAULT_PHASE_MODELS, ...(settings.phaseModels ?? current.phaseModels) },
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none', defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none', defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models enabledCursorModels: allCursorModels, // Always use ALL cursor models
@@ -771,6 +773,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enableSubagents: settings.enableSubagents ?? true, enableSubagents: settings.enableSubagents ?? true,
subagentsSources: settings.subagentsSources ?? ['user', 'project'], subagentsSources: settings.subagentsSources ?? ['user', 'project'],
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? true, autoLoadClaudeMd: settings.autoLoadClaudeMd ?? true,
useClaudeCodeSystemPrompt: settings.useClaudeCodeSystemPrompt ?? true,
skipSandboxWarning: settings.skipSandboxWarning ?? false, skipSandboxWarning: settings.skipSandboxWarning ?? false,
codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false, codexAutoLoadAgents: settings.codexAutoLoadAgents ?? false,
codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write', codexSandboxMode: settings.codexSandboxMode ?? 'workspace-write',
@@ -896,6 +899,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enableSubagents: state.enableSubagents, enableSubagents: state.enableSubagents,
subagentsSources: state.subagentsSources, subagentsSources: state.subagentsSources,
autoLoadClaudeMd: state.autoLoadClaudeMd, autoLoadClaudeMd: state.autoLoadClaudeMd,
useClaudeCodeSystemPrompt: state.useClaudeCodeSystemPrompt,
skipSandboxWarning: state.skipSandboxWarning, skipSandboxWarning: state.skipSandboxWarning,
codexAutoLoadAgents: state.codexAutoLoadAgents, codexAutoLoadAgents: state.codexAutoLoadAgents,
codexSandboxMode: state.codexSandboxMode, codexSandboxMode: state.codexSandboxMode,

View File

@@ -25,6 +25,7 @@ import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
DEFAULT_COPILOT_MODEL, DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY,
DEFAULT_PHASE_MODELS,
getAllOpencodeModelIds, getAllOpencodeModelIds,
getAllCursorModelIds, getAllCursorModelIds,
getAllGeminiModelIds, getAllGeminiModelIds,
@@ -85,6 +86,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enabledDynamicModelIds', 'enabledDynamicModelIds',
'disabledProviders', 'disabledProviders',
'autoLoadClaudeMd', 'autoLoadClaudeMd',
'useClaudeCodeSystemPrompt',
'keyboardShortcuts', 'keyboardShortcuts',
'mcpServers', 'mcpServers',
'defaultEditorCommand', 'defaultEditorCommand',
@@ -100,6 +102,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'subagentsSources', 'subagentsSources',
'promptCustomization', 'promptCustomization',
'eventHooks', 'eventHooks',
'featureTemplates',
'claudeCompatibleProviders', 'claudeCompatibleProviders',
'claudeApiProfiles', 'claudeApiProfiles',
'activeClaudeApiProfileId', 'activeClaudeApiProfileId',
@@ -727,6 +730,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
serverSettings.phaseModels.memoryExtractionModel serverSettings.phaseModels.memoryExtractionModel
), ),
commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel), commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
prDescriptionModel: migratePhaseModelEntry(serverSettings.phaseModels.prDescriptionModel),
} }
: undefined; : undefined;
@@ -785,7 +789,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
enableRequestLogging: serverSettings.enableRequestLogging ?? true, enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel, enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel, validationModel: serverSettings.validationModel,
phaseModels: migratedPhaseModels ?? serverSettings.phaseModels, phaseModels: {
...DEFAULT_PHASE_MODELS,
...(migratedPhaseModels ?? serverSettings.phaseModels),
},
enabledCursorModels: allCursorModels, // Always use ALL cursor models enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefault, cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels, enabledOpencodeModels: sanitizedEnabledOpencodeModels,
@@ -797,6 +804,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
enabledDynamicModelIds: sanitizedDynamicModelIds, enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [], disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true, autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? true,
useClaudeCodeSystemPrompt: serverSettings.useClaudeCodeSystemPrompt ?? true,
keyboardShortcuts: { keyboardShortcuts: {
...currentAppState.keyboardShortcuts, ...currentAppState.keyboardShortcuts,
...(serverSettings.keyboardShortcuts as unknown as Partial< ...(serverSettings.keyboardShortcuts as unknown as Partial<
@@ -836,6 +844,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
recentFolders: serverSettings.recentFolders ?? [], recentFolders: serverSettings.recentFolders ?? [],
// Event hooks // Event hooks
eventHooks: serverSettings.eventHooks ?? [], eventHooks: serverSettings.eventHooks ?? [],
// Feature templates
featureTemplates: serverSettings.featureTemplates ?? [],
// Codex CLI Settings // Codex CLI Settings
codexAutoLoadAgents: serverSettings.codexAutoLoadAgents ?? false, codexAutoLoadAgents: serverSettings.codexAutoLoadAgents ?? false,
codexSandboxMode: serverSettings.codexSandboxMode ?? 'workspace-write', codexSandboxMode: serverSettings.codexSandboxMode ?? 'workspace-write',

View File

@@ -779,7 +779,8 @@ export interface ElectronAPI {
generate: ( generate: (
projectPath: string, projectPath: string,
prompt: string, prompt: string,
model?: string model?: string,
branchName?: string
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>; stop: () => Promise<{ success: boolean; error?: string }>;
status: (projectPath: string) => Promise<{ status: (projectPath: string) => Promise<{

View File

@@ -2899,9 +2899,10 @@ export class HttpApiClient implements ElectronAPI {
generate: ( generate: (
projectPath: string, projectPath: string,
prompt: string, prompt: string,
model?: string model?: string,
branchName?: string
): Promise<{ success: boolean; error?: string }> => ): Promise<{ success: boolean; error?: string }> =>
this.post('/api/backlog-plan/generate', { projectPath, prompt, model }), this.post('/api/backlog-plan/generate', { projectPath, prompt, model, branchName }),
stop: (): Promise<{ success: boolean; error?: string }> => stop: (): Promise<{ success: boolean; error?: string }> =>
this.post('/api/backlog-plan/stop', {}), this.post('/api/backlog-plan/stop', {}),

View File

@@ -22,6 +22,7 @@ import type {
ServerLogLevel, ServerLogLevel,
ParsedTask, ParsedTask,
PlanSpec, PlanSpec,
FeatureTemplate,
} from '@automaker/types'; } from '@automaker/types';
import { import {
getAllCursorModelIds, getAllCursorModelIds,
@@ -341,6 +342,7 @@ const initialState: AppState = {
copilotDefaultModel: DEFAULT_COPILOT_MODEL, copilotDefaultModel: DEFAULT_COPILOT_MODEL,
disabledProviders: [], disabledProviders: [],
autoLoadClaudeMd: false, autoLoadClaudeMd: false,
useClaudeCodeSystemPrompt: true,
skipSandboxWarning: false, skipSandboxWarning: false,
mcpServers: [], mcpServers: [],
defaultEditorCommand: null, defaultEditorCommand: null,
@@ -355,6 +357,7 @@ const initialState: AppState = {
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
promptCustomization: {}, promptCustomization: {},
eventHooks: [], eventHooks: [],
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
claudeCompatibleProviders: [], claudeCompatibleProviders: [],
claudeApiProfiles: [], claudeApiProfiles: [],
activeClaudeApiProfileId: null, activeClaudeApiProfileId: null,
@@ -1393,6 +1396,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
logger.error('Failed to sync autoLoadClaudeMd:', error); logger.error('Failed to sync autoLoadClaudeMd:', error);
} }
}, },
setUseClaudeCodeSystemPrompt: async (enabled) => {
set({ useClaudeCodeSystemPrompt: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ useClaudeCodeSystemPrompt: enabled });
} catch (error) {
logger.error('Failed to sync useClaudeCodeSystemPrompt:', error);
}
},
setSkipSandboxWarning: async (skip) => { setSkipSandboxWarning: async (skip) => {
set({ skipSandboxWarning: skip }); set({ skipSandboxWarning: skip });
try { try {
@@ -1437,6 +1449,69 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
} }
}, },
// Feature Template actions
setFeatureTemplates: async (templates) => {
set({ featureTemplates: templates });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: templates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
addFeatureTemplate: async (template) => {
set((state) => ({
featureTemplates: [...state.featureTemplates, template],
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
updateFeatureTemplate: async (id, updates) => {
set((state) => ({
featureTemplates: state.featureTemplates.map((t) => (t.id === id ? { ...t, ...updates } : t)),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
deleteFeatureTemplate: async (id) => {
set((state) => ({
featureTemplates: state.featureTemplates.filter((t) => t.id !== id),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
reorderFeatureTemplates: async (templateIds) => {
set((state) => {
const templateMap = new Map(state.featureTemplates.map((t) => [t.id, t]));
const reordered: FeatureTemplate[] = [];
templateIds.forEach((id, index) => {
const template = templateMap.get(id);
if (template) {
reordered.push({ ...template, order: index });
}
});
return { featureTemplates: reordered };
});
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
// Claude-Compatible Provider actions (new system) // Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: async (provider) => { addClaudeCompatibleProvider: async (provider) => {
set((state) => ({ set((state) => ({

View File

@@ -23,6 +23,7 @@ import type {
SidebarStyle, SidebarStyle,
ThinkingLevel, ThinkingLevel,
ReasoningEffort, ReasoningEffort,
FeatureTemplate,
} from '@automaker/types'; } from '@automaker/types';
import type { import type {
@@ -234,6 +235,7 @@ export interface AppState {
// Claude Agent SDK Settings // Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
useClaudeCodeSystemPrompt: boolean; // Use Claude Code's built-in system prompt as the base
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
// MCP Servers // MCP Servers
@@ -265,6 +267,9 @@ export interface AppState {
// Event Hooks // Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
// Feature Templates
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
// Claude-Compatible Providers (new system) // Claude-Compatible Providers (new system)
claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
@@ -620,6 +625,7 @@ export interface AppActions {
// Claude Agent SDK Settings actions // Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>; setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setUseClaudeCodeSystemPrompt: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>; setSkipSandboxWarning: (skip: boolean) => Promise<void>;
// Editor Configuration actions // Editor Configuration actions
@@ -640,6 +646,13 @@ export interface AppActions {
// Event Hook actions // Event Hook actions
setEventHooks: (hooks: EventHook[]) => Promise<void>; setEventHooks: (hooks: EventHook[]) => Promise<void>;
// Feature Template actions
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
addFeatureTemplate: (template: FeatureTemplate) => Promise<void>;
updateFeatureTemplate: (id: string, updates: Partial<FeatureTemplate>) => Promise<void>;
deleteFeatureTemplate: (id: string) => Promise<void>;
reorderFeatureTemplates: (templateIds: string[]) => Promise<void>;
// Claude-Compatible Provider actions (new system) // Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>; addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise<void>;
updateClaudeCompatibleProvider: ( updateClaudeCompatibleProvider: (

View File

@@ -242,6 +242,10 @@ export default defineConfig(({ command }) => {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
// Deduplicate React to prevent "Cannot read properties of null (reading 'useState')"
// errors caused by CJS packages (like use-sync-external-store used by zustand@4 inside
// @xyflow/react) resolving React to a different instance than the pre-bundled ESM React.
dedupe: ['react', 'react-dom'],
}, },
server: { server: {
host: process.env.HOST || '0.0.0.0', host: process.env.HOST || '0.0.0.0',
@@ -281,7 +285,13 @@ export default defineConfig(({ command }) => {
// Manual chunks for optimal caching and loading on mobile // Manual chunks for optimal caching and loading on mobile
manualChunks(id) { manualChunks(id) {
// Vendor: React core (rarely changes, cache long-term) // Vendor: React core (rarely changes, cache long-term)
if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) { // Also include use-sync-external-store here since it uses CJS require('react')
// and must be in the same chunk as React to prevent null dispatcher errors.
if (
id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/') ||
id.includes('node_modules/use-sync-external-store/')
) {
return 'vendor-react'; return 'vendor-react';
} }
// Vendor: TanStack Router + Query (used on every page) // Vendor: TanStack Router + Query (used on every page)
@@ -328,6 +338,11 @@ export default defineConfig(({ command }) => {
}, },
optimizeDeps: { optimizeDeps: {
exclude: ['@automaker/platform'], exclude: ['@automaker/platform'],
// Ensure CJS packages that use require('react') are pre-bundled together with React
// so that the CJS interop resolves to the same React instance as the rest of the app.
// Without this, use-sync-external-store (used by zustand@4 inside @xyflow/react) may
// get a null React reference, causing "Cannot read properties of null (reading 'useState')".
include: ['react', 'react-dom', 'use-sync-external-store'],
}, },
define: { define: {
__APP_VERSION__: JSON.stringify(appVersion), __APP_VERSION__: JSON.stringify(appVersion),

View File

@@ -173,6 +173,8 @@ export type {
EventHookHttpAction, EventHookHttpAction,
EventHookAction, EventHookAction,
EventHook, EventHook,
// Feature template types
FeatureTemplate,
// Claude-compatible provider types (new) // Claude-compatible provider types (new)
ApiKeySource, ApiKeySource,
ClaudeCompatibleProviderType, ClaudeCompatibleProviderType,
@@ -203,6 +205,8 @@ export {
getDefaultThinkingLevel, getDefaultThinkingLevel,
// Event hook constants // Event hook constants
EVENT_HOOK_TRIGGER_LABELS, EVENT_HOOK_TRIGGER_LABELS,
// Feature template constants
DEFAULT_FEATURE_TEMPLATES,
// Claude-compatible provider templates (new) // Claude-compatible provider templates (new)
CLAUDE_PROVIDER_TEMPLATES, CLAUDE_PROVIDER_TEMPLATES,
// Claude API profile constants (deprecated) // Claude API profile constants (deprecated)

View File

@@ -163,6 +163,13 @@ export interface ExecuteOptions {
systemPrompt?: string | SystemPromptPreset; systemPrompt?: string | SystemPromptPreset;
maxTurns?: number; maxTurns?: number;
allowedTools?: string[]; allowedTools?: string[];
/**
* Restrict which built-in tools are available to the subprocess.
* - string[] - Array of specific tool names (e.g., ['Bash', 'Read', 'Edit'])
* - [] (empty array) - Disable all built-in tools (text generation only)
* Unlike allowedTools (which controls auto-approval), this controls tool availability.
*/
tools?: string[];
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;
/** If true, allows all MCP tools unrestricted (no approval needed). Default: false */ /** If true, allows all MCP tools unrestricted (no approval needed). Default: false */
mcpUnrestrictedTools?: boolean; mcpUnrestrictedTools?: boolean;

View File

@@ -209,6 +209,87 @@ export type TerminalPromptTheme =
/** PlanningMode - Planning levels for feature generation workflows */ /** PlanningMode - Planning levels for feature generation workflows */
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
/**
* FeatureTemplate - Pre-configured task prompts for quick feature creation
*
* Templates allow users to quickly create features with pre-written prompts.
* Built-in templates are protected from deletion but can be disabled.
*/
export interface FeatureTemplate {
/** Unique identifier */
id: string;
/** Display name (shown in dropdown) */
name: string;
/** Pre-written prompt/task description */
prompt: string;
/** Optional preferred model for this template */
model?: PhaseModelEntry;
/** Whether this is a built-in template (protected from deletion) */
isBuiltIn?: boolean;
/** Whether this template is enabled (hidden if false) */
enabled?: boolean;
/** Sort order (lower = higher in list) */
order?: number;
}
/** Default built-in feature templates */
export const DEFAULT_FEATURE_TEMPLATES: FeatureTemplate[] = [
{
id: 'run-tests-lint-format',
name: 'Run tests, lint, and format',
prompt:
'Run all tests, lint checks, and format the codebase. Fix any issues found. Ensure the code passes all quality checks before marking complete.',
isBuiltIn: true,
enabled: true,
order: 0,
},
{
id: 'write-tests-for-changes',
name: 'Write tests for current changes',
prompt:
'Analyze the current uncommitted changes and write comprehensive tests for the modified code. Focus on edge cases and ensure good test coverage.',
isBuiltIn: true,
enabled: true,
order: 1,
},
{
id: 'review-recent-changes',
name: 'Review and summarize recent changes',
prompt:
'Review the recent commits and changes in this codebase. Provide a summary of what was changed, identify any potential issues, and suggest improvements.',
isBuiltIn: true,
enabled: true,
order: 2,
},
{
id: 'fix-lint-errors',
name: 'Fix lint errors',
prompt:
'Run the linter and fix all reported errors. Ensure the codebase passes lint checks without warnings.',
isBuiltIn: true,
enabled: true,
order: 3,
},
{
id: 'update-dependencies',
name: 'Update and test dependencies',
prompt:
'Check for outdated dependencies, update them to their latest stable versions, and run tests to ensure nothing breaks. Document any breaking changes or migration steps required.',
isBuiltIn: true,
enabled: true,
order: 4,
},
{
id: 'code-review-and-fix',
name: 'Code review and fix issues',
prompt:
'Perform a thorough code review of the current codebase. Identify and fix any issues found, including: code quality problems, potential bugs, security vulnerabilities, performance bottlenecks, and violations of best practices. After fixing all issues, run tests and lint to verify everything passes.',
isBuiltIn: true,
enabled: true,
order: 5,
},
];
/** ServerLogLevel - Log verbosity level for the API server */ /** ServerLogLevel - Log verbosity level for the API server */
export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug'; export type ServerLogLevel = 'error' | 'warn' | 'info' | 'debug';
@@ -1205,6 +1286,8 @@ export interface GlobalSettings {
// Claude Agent SDK Settings // Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option */ /** Auto-load CLAUDE.md files using SDK's settingSources option */
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
useClaudeCodeSystemPrompt?: boolean;
/** Skip the sandbox environment warning dialog on startup */ /** Skip the sandbox environment warning dialog on startup */
skipSandboxWarning?: boolean; skipSandboxWarning?: boolean;
@@ -1284,6 +1367,13 @@ export interface GlobalSettings {
*/ */
eventHooks?: EventHook[]; eventHooks?: EventHook[];
// Feature Templates Configuration
/**
* Feature templates for quick task creation from the Add Feature dropdown
* Built-in templates are protected from deletion but can be disabled
*/
featureTemplates?: FeatureTemplate[];
// Claude-Compatible Providers Configuration // Claude-Compatible Providers Configuration
/** /**
* Claude-compatible provider configurations. * Claude-compatible provider configurations.
@@ -1445,6 +1535,8 @@ export interface ProjectSettings {
// Claude Agent SDK Settings // Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option (project override) */ /** Auto-load CLAUDE.md files using SDK's settingSources option (project override) */
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt (project override) */
useClaudeCodeSystemPrompt?: boolean;
// Subagents Configuration // Subagents Configuration
/** /**
@@ -1663,6 +1755,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
currentWorktreeByProject: {}, currentWorktreeByProject: {},
autoLoadClaudeMd: true, autoLoadClaudeMd: true,
useClaudeCodeSystemPrompt: true,
skipSandboxWarning: false, skipSandboxWarning: false,
codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS,
codexSandboxMode: DEFAULT_CODEX_SANDBOX_MODE, codexSandboxMode: DEFAULT_CODEX_SANDBOX_MODE,
@@ -1680,6 +1773,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
subagentsSources: ['user', 'project'], subagentsSources: ['user', 'project'],
// Event hooks // Event hooks
eventHooks: [], eventHooks: [],
// Feature templates
featureTemplates: DEFAULT_FEATURE_TEMPLATES,
// New provider system // New provider system
claudeCompatibleProviders: [], claudeCompatibleProviders: [],
// Deprecated - kept for migration // Deprecated - kept for migration