Merge branch 'v0.14.0rc' into feature/bug-complete-fix-for-the-plan-mode-system-inside-sbyt

Resolved conflict in apps/ui/src/hooks/use-query-invalidation.ts by:
- Keeping the refactored structure from v0.14.0rc (using constants and hasFeatureId() type guard)
- Adding the additional event types from the feature branch (auto_mode_task_status, auto_mode_summary) to SINGLE_FEATURE_INVALIDATION_EVENTS constant

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-24 21:16:43 +01:00
38 changed files with 1668 additions and 337 deletions

View File

@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
* for this duration, the process is killed. For reasoning models with high
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
*
* For feature generation (which can generate 50+ features), we use a much longer
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
*
* @see calculateReasoningTimeout from @automaker/types
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
// for the model to generate reasoning tokens before producing output.
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
//
// For feature generation with 'xhigh', use the extended 5-minute base timeout
// since generating 50+ features takes significantly longer than normal operations.
const baseTimeout =
options.reasoningEffort === 'xhigh'
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
: CODEX_CLI_TIMEOUT_MS;
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
const stream = spawnJSONLProcess({
command: commandPath,

View File

@@ -8,10 +8,11 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
const DEFAULT_MAX_FEATURES = 50;
/**
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response
*/
interface FeaturesExtractionResult {
features: Array<{
id: string;
category?: string;
title: string;
description: string;
priority?: number;
complexity?: 'simple' | 'moderate' | 'complex';
dependencies?: string[];
}>;
}
/**
* JSON schema for features output format (Claude/Codex structured output)
*/
const featuresOutputSchema = {
type: 'object',
properties: {
features: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
category: { type: 'string', description: 'Feature category' },
title: { type: 'string', description: 'Short, descriptive title' },
description: { type: 'string', description: 'Detailed feature description' },
priority: {
type: 'number',
description: 'Priority level: 1 (highest) to 5 (lowest)',
},
complexity: {
type: 'string',
enum: ['simple', 'moderate', 'complex'],
description: 'Implementation complexity',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'IDs of features this depends on',
},
},
required: ['id', 'title', 'description'],
},
},
},
required: ['features'],
} as const;
export async function generateFeaturesFromSpec(
projectPath: string,
events: EventEmitter,
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
provider: undefined,
credentials: undefined,
};
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Codex models need extended timeout for generating many features.
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
// The Codex provider has a special 5-minute base timeout for feature generation.
const isCodex = isCodexModel(model);
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
if (isCodex) {
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
}
if (effectiveReasoningEffort) {
logger.info('Reasoning effort:', effectiveReasoningEffort);
}
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
3. The JSON must have this exact structure:
{
"features": [
{
"id": "unique-feature-id",
"category": "Category Name",
"title": "Short Feature Title",
"description": "Detailed description of the feature",
"priority": 1,
"complexity": "simple|moderate|complex",
"dependencies": ["other-feature-id"]
}
]
}
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
5. Priority ranges from 1 (highest) to 5 (lowest)
6. Complexity must be one of: "simple", "moderate", "complex"
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
}
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
prompt: finalPrompt,
model,
cwd: projectPath,
maxTurns: 250,
allowedTools: ['Read', 'Glob', 'Grep'],
abortController,
thinkingLevel,
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: featuresOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
@@ -163,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
},
});
const responseText = result.text;
// Get response content - prefer structured output if available
let contentForParsing: string;
if (result.structured_output) {
// Use structured output from Claude/Codex models
logger.info('✅ Received structured output from model');
contentForParsing = JSON.stringify(result.structured_output);
logger.debug('Structured output:', contentForParsing);
} else {
// Use text response (for non-Claude/Codex models or fallback)
// Pre-extract JSON to handle conversational text that may surround the JSON response
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
const rawText = result.text;
logger.info(`Feature stream complete.`);
logger.info(`Feature response length: ${responseText.length} chars`);
logger.info(`Feature response length: ${rawText.length} chars`);
logger.info('========== FULL RESPONSE TEXT ==========');
logger.info(responseText);
logger.info(rawText);
logger.info('========== END RESPONSE TEXT ==========');
await parseAndCreateFeatures(projectPath, responseText, events);
// Pre-extract JSON from response - handles conversational text around the JSON
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
logger,
});
if (extracted) {
contentForParsing = JSON.stringify(extracted);
logger.info('✅ Pre-extracted JSON from text response');
} else {
// If pre-extraction fails, we know the next step will also fail.
// Throw an error here to avoid redundant parsing and make the failure point clearer.
logger.error(
'❌ Could not extract features JSON from model response. Full response text was:\n' +
rawText
);
const errorMessage =
'Failed to parse features from model response: No valid JSON with a "features" array found.';
events.emit('spec-regeneration:event', {
type: 'spec_regeneration_error',
error: errorMessage,
projectPath: projectPath,
});
throw new Error(errorMessage);
}
}
await parseAndCreateFeatures(projectPath, contentForParsing, events);
logger.debug('========== generateFeaturesFromSpec() completed ==========');
}

View File

@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../lib/json-extractor.js';
import { streamingQuery } from '../../providers/simple-query-service.js';
@@ -120,10 +120,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
let responseText = '';
let structuredOutput: SpecOutput | null = null;
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
const useStructuredOutput = !isCursorModel(model);
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Build the final prompt - for Cursor, include JSON schema instructions
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
let finalPrompt = prompt;
if (!useStructuredOutput) {
finalPrompt = `${prompt}

View File

@@ -10,9 +10,10 @@
import * as secureFs from '../../lib/secure-fs.js';
import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { extractJson } from '../../lib/json-extractor.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import {
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecSync');
/**
* Type for extracted tech stack JSON response
*/
interface TechStackExtractionResult {
technologies: string[];
}
/**
* JSON schema for tech stack analysis output (Claude/Codex structured output)
*/
const techStackOutputSchema = {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
description: 'List of technologies detected in the project',
},
},
required: ['technologies'],
} as const;
/**
* Result of a sync operation
*/
@@ -176,8 +199,14 @@ export async function syncSpec(
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
// Determine if we should use structured output based on model type
const useStructuredOutput = supportsStructuredOutput(model);
logger.info(
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
Current known technologies: ${currentTechStack.join(', ')}
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
"technologies": ["Technology 1", "Technology 2", ...]
}`;
// Add explicit JSON instructions for non-Claude/Codex models
if (!useStructuredOutput) {
techAnalysisPrompt = `${techAnalysisPrompt}
CRITICAL INSTRUCTIONS:
1. DO NOT write any files. Return the JSON in your response only.
2. Your entire response should be valid JSON starting with { and ending with }.
3. No explanations, no markdown, no text before or after the JSON.`;
}
try {
const techResult = await streamingQuery({
prompt: techAnalysisPrompt,
@@ -206,17 +245,44 @@ Return ONLY this JSON format, no other text:
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
schema: techStackOutputSchema,
}
: undefined,
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
});
// Parse tech stack from response
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (Array.isArray(parsed.technologies)) {
const newTechStack = parsed.technologies as string[];
// Parse tech stack from response - prefer structured output if available
let parsedTechnologies: string[] | null = null;
if (techResult.structured_output) {
// Use structured output from Claude/Codex models
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
if (Array.isArray(structured.technologies)) {
parsedTechnologies = structured.technologies;
logger.info('✅ Received structured output for tech analysis');
}
} else {
// Fall back to text parsing for non-Claude/Codex models
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
logger,
requiredKey: 'technologies',
requireArray: true,
});
if (extracted && Array.isArray(extracted.technologies)) {
parsedTechnologies = extracted.technologies;
logger.info('✅ Extracted tech stack from text response');
} else {
logger.warn('⚠️ Failed to extract tech stack JSON from response');
}
}
if (parsedTechnologies) {
const newTechStack = parsedTechnologies;
// Calculate differences
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
@@ -235,17 +301,13 @@ Return ONLY this JSON format, no other text:
}
// Update spec with new tech stack if there are changes
if (
result.techStackUpdates.added.length > 0 ||
result.techStackUpdates.removed.length > 0
) {
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
specContent = updateTechnologyStack(specContent, newTechStack);
logger.info(
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
);
}
}
}
} catch (error) {
logger.warn('Failed to analyze tech stack:', error);
// Continue with other sync operations

View File

@@ -23,6 +23,7 @@ import {
isCodexModel,
isCursorModel,
isOpencodeModel,
supportsStructuredOutput,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { extractJson } from '../../../lib/json-extractor.js';
@@ -124,8 +125,9 @@ async function runValidation(
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
// Determine if we should use structured output based on model type
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
const useStructuredOutput = supportsStructuredOutput(model);
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
let finalPrompt = basePrompt;

View File

@@ -349,6 +349,7 @@ interface RunningFeature {
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
leaseCount: number;
model?: string;
provider?: ModelProvider;
}
@@ -450,6 +451,54 @@ export class AutoModeService {
this.settingsService = settingsService ?? null;
}
private acquireRunningFeature(params: {
featureId: string;
projectPath: string;
isAutoMode: boolean;
allowReuse?: boolean;
abortController?: AbortController;
}): RunningFeature {
const existing = this.runningFeatures.get(params.featureId);
if (existing) {
if (!params.allowReuse) {
throw new Error('already running');
}
existing.leaseCount = (existing.leaseCount ?? 1) + 1;
return existing;
}
const abortController = params.abortController ?? new AbortController();
const entry: RunningFeature = {
featureId: params.featureId,
projectPath: params.projectPath,
worktreePath: null,
branchName: null,
abortController,
isAutoMode: params.isAutoMode,
startTime: Date.now(),
leaseCount: 1,
};
this.runningFeatures.set(params.featureId, entry);
return entry;
}
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
const entry = this.runningFeatures.get(featureId);
if (!entry) {
return;
}
if (options?.force) {
this.runningFeatures.delete(featureId);
return;
}
entry.leaseCount = (entry.leaseCount ?? 1) - 1;
if (entry.leaseCount <= 0) {
this.runningFeatures.delete(featureId);
}
}
/**
* Reset features that were stuck in transient states due to server crash
* Called when auto mode is enabled to clean up from previous session
@@ -1270,24 +1319,17 @@ export class AutoModeService {
providedWorktreePath?: string,
options?: {
continuationPrompt?: string;
/** Internal flag: set to true when called from a method that already tracks the feature */
_calledInternally?: boolean;
}
): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error('already running');
}
// Add to running features immediately to prevent race conditions
const abortController = new AbortController();
const tempRunningFeature: RunningFeature = {
const tempRunningFeature = this.acquireRunningFeature({
featureId,
projectPath,
worktreePath: null,
branchName: null,
abortController,
isAutoMode,
startTime: Date.now(),
};
this.runningFeatures.set(featureId, tempRunningFeature);
allowReuse: options?._calledInternally,
});
const abortController = tempRunningFeature.abortController;
// Save execution state when feature starts
if (isAutoMode) {
@@ -1324,9 +1366,8 @@ export class AutoModeService {
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
// Recursively call executeFeature with the continuation prompt
// Remove from running features temporarily, it will be added back
this.runningFeatures.delete(featureId);
return this.executeFeature(
// Feature is already tracked, the recursive call will reuse the entry
return await this.executeFeature(
projectPath,
featureId,
useWorktrees,
@@ -1334,6 +1375,7 @@ export class AutoModeService {
providedWorktreePath,
{
continuationPrompt,
_calledInternally: true,
}
);
}
@@ -1343,9 +1385,8 @@ export class AutoModeService {
logger.info(
`Feature ${featureId} has existing context, resuming instead of starting fresh`
);
// Remove from running features temporarily, resumeFeature will add it back
this.runningFeatures.delete(featureId);
return this.resumeFeature(projectPath, featureId, useWorktrees);
// Feature is already tracked, resumeFeature will reuse the entry
return await this.resumeFeature(projectPath, featureId, useWorktrees, true);
}
}
@@ -1604,7 +1645,7 @@ export class AutoModeService {
logger.info(
`Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`
);
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
// Update execution state after feature completes
if (this.autoLoopRunning && projectPath) {
@@ -1784,7 +1825,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Remove from running features immediately to allow resume
// The abort signal will still propagate to stop any ongoing execution
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId, { force: true });
return true;
}
@@ -1792,11 +1833,21 @@ Complete the pipeline step instructions above. Review the previous work and appl
/**
* Resume a feature (continues from saved context)
*/
async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise<void> {
if (this.runningFeatures.has(featureId)) {
throw new Error('already running');
}
async resumeFeature(
projectPath: string,
featureId: string,
useWorktrees = false,
/** Internal flag: set to true when called from a method that already tracks the feature */
_calledInternally = false
): Promise<void> {
this.acquireRunningFeature({
featureId,
projectPath,
isAutoMode: false,
allowReuse: _calledInternally,
});
try {
// Load feature to check status
const feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
@@ -1812,7 +1863,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
if (pipelineInfo.isPipeline) {
// Feature stuck in pipeline - use pipeline resume
return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
// Pass _alreadyTracked to prevent double-tracking
return await this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo);
}
// Normal resume flow for non-pipeline features
@@ -1830,12 +1882,18 @@ Complete the pipeline step instructions above. Review the previous work and appl
if (hasContext) {
// Load previous context and continue
// executeFeatureWithContext -> executeFeature will see feature is already tracked
const context = (await secureFs.readFile(contextPath, 'utf-8')) as string;
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
}
// No context, start fresh - executeFeature will handle adding to runningFeatures
return this.executeFeature(projectPath, featureId, useWorktrees, false);
// No context, start fresh - executeFeature will see feature is already tracked
return await this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
_calledInternally: true,
});
} finally {
this.releaseRunningFeature(featureId);
}
}
/**
@@ -1885,7 +1943,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Reset status to in_progress and start fresh
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
return this.executeFeature(projectPath, featureId, useWorktrees, false);
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
_calledInternally: true,
});
}
// Edge Case 2: Step no longer exists in pipeline config
@@ -2031,17 +2091,14 @@ Complete the pipeline step instructions above. Review the previous work and appl
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
);
// Add to running features immediately
const abortController = new AbortController();
this.runningFeatures.set(featureId, {
const runningEntry = this.acquireRunningFeature({
featureId,
projectPath,
worktreePath: null, // Will be set below
branchName: feature.branchName ?? null,
abortController,
isAutoMode: false,
startTime: Date.now(),
allowReuse: true,
});
const abortController = runningEntry.abortController;
runningEntry.branchName = feature.branchName ?? null;
try {
// Validate project path
@@ -2066,11 +2123,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
validateWorkingDirectory(workDir);
// Update running feature with worktree info
const runningFeature = this.runningFeatures.get(featureId);
if (runningFeature) {
runningFeature.worktreePath = worktreePath;
runningFeature.branchName = branchName ?? null;
}
runningEntry.worktreePath = worktreePath;
runningEntry.branchName = branchName ?? null;
// Emit resume event
this.emitAutoModeEvent('auto_mode_feature_start', {
@@ -2148,7 +2202,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
});
}
} finally {
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
}
}
@@ -2165,11 +2219,12 @@ Complete the pipeline step instructions above. Review the previous work and appl
// Validate project path early for fast failure
validateWorkingDirectory(projectPath);
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
const abortController = new AbortController();
const runningEntry = this.acquireRunningFeature({
featureId,
projectPath,
isAutoMode: false,
});
const abortController = runningEntry.abortController;
// Load feature info for context FIRST to get branchName
const feature = await this.loadFeature(projectPath, featureId);
@@ -2251,17 +2306,10 @@ Address the follow-up instructions above. Review the previous work and make the
const provider = ProviderFactory.getProviderNameForModel(model);
logger.info(`Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`);
this.runningFeatures.set(featureId, {
featureId,
projectPath,
worktreePath,
branchName,
abortController,
isAutoMode: false,
startTime: Date.now(),
model,
provider,
});
runningEntry.worktreePath = worktreePath;
runningEntry.branchName = branchName;
runningEntry.model = model;
runningEntry.provider = provider;
try {
// Update feature status to in_progress BEFORE emitting event
@@ -2409,7 +2457,7 @@ Address the follow-up instructions above. Review the previous work and make the
}
}
} finally {
this.runningFeatures.delete(featureId);
this.releaseRunningFeature(featureId);
}
}
@@ -4861,6 +4909,7 @@ After generating the revised spec, output:
return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, {
continuationPrompt: prompt,
_calledInternally: true,
});
}

View File

@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
);
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
// xhigh reasoning effort should have 4x the default timeout (120000ms)
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
expect(call.timeout).toBe(
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
);
});
it('uses default timeout when no reasoning effort is specified', async () => {

View File

@@ -6,14 +6,25 @@ import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import { useAppStore } from './store/app-store';
import './styles/global.css';
import './styles/theme-imports';
import './styles/font-imports';
const logger = createLogger('App');
// Key for localStorage to persist splash screen preference
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
export default function App() {
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
const [showSplash, setShowSplash] = useState(() => {
// Check localStorage for user preference (available synchronously)
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
if (savedPreference === 'true') {
return false;
}
// Only show splash once per session
if (sessionStorage.getItem('automaker-splash-shown')) {
return false;
@@ -21,6 +32,11 @@ export default function App() {
return true;
});
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
useEffect(() => {
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
}, [disableSplashScreen]);
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
useEffect(() => {
@@ -61,7 +77,7 @@ export default function App() {
return (
<>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
</>
);
}

View File

@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -37,9 +37,19 @@ const buttonVariants = cva(
}
);
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Spinner size="sm" className={className} />;
/** Button variants that have colored backgrounds requiring foreground spinner color */
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
function getSpinnerVariant(
buttonVariant: VariantProps<typeof buttonVariants>['variant']
): SpinnerVariant {
const variant = buttonVariant ?? 'default';
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
return 'foreground';
}
// outline, secondary, ghost, link, animated-outline use standard backgrounds
return 'primary';
}
function Button({
@@ -57,6 +67,7 @@ function Button({
loading?: boolean;
}) {
const isDisabled = disabled || loading;
const spinnerVariant = getSpinnerVariant(variant);
// Special handling for animated-outline variant
if (variant === 'animated-outline' && !asChild) {
@@ -83,7 +94,7 @@ function Button({
size === 'icon' && 'p-0 gap-0'
)}
>
{loading && <ButtonSpinner />}
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{children}
</span>
</button>
@@ -99,7 +110,7 @@ function Button({
disabled={isDisabled}
{...props}
>
{loading && <ButtonSpinner />}
{loading && <Spinner size="sm" variant={spinnerVariant} />}
{children}
</Comp>
);

View File

@@ -1,7 +1,8 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
xl: 'h-8 w-8',
};
const variantClasses: Record<SpinnerVariant, string> = {
primary: 'text-primary',
foreground: 'text-primary-foreground',
muted: 'text-muted-foreground',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Color variant - use 'foreground' when on primary backgrounds */
variant?: SpinnerVariant;
/** Additional class names */
className?: string;
}
@@ -21,11 +30,12 @@ interface SpinnerProps {
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
* Use variant='foreground' when placing on primary-colored backgrounds.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
aria-hidden="true"
/>
);

View File

@@ -261,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Spinner size="xs" />}
{isActive && <Spinner size="xs" variant="foreground" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -463,6 +463,16 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Aggregate running auto tasks across all worktrees for this project
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const runningAutoTasksAllWorktrees = useMemo(() => {
if (!currentProject?.id) return [];
const prefix = `${currentProject.id}::`;
return Object.entries(autoModeByWorktree)
.filter(([key]) => key.startsWith(prefix))
.flatMap(([, state]) => state.runningTasks ?? []);
}, [autoModeByWorktree, currentProject?.id]);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
@@ -1372,7 +1382,7 @@ export function BoardView() {
setWorktreeRefreshKey((k) => k + 1);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
runningFeatureIds={runningAutoTasksAllWorktrees}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,

View File

@@ -330,7 +330,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Merging...
</>
) : (

View File

@@ -210,7 +210,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

@@ -1,5 +1,16 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeDropdown } from './worktree-dropdown';
export type { WorktreeDropdownProps } from './worktree-dropdown';
export { WorktreeDropdownItem } from './worktree-dropdown-item';
export type { WorktreeDropdownItemProps } from './worktree-dropdown-item';
export {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export type { TestStatus } from './worktree-indicator-utils';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -319,7 +319,7 @@ export function WorktreeActionsDropdown({
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="ml-1.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>

View File

@@ -0,0 +1,202 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
/**
* Maximum characters for branch name before truncation in dropdown items.
* Set to 28 to accommodate longer names in the wider dropdown menu while
* still fitting comfortably with all status indicators.
*/
const MAX_ITEM_BRANCH_NAME_LENGTH = 28;
export interface WorktreeDropdownItemProps {
/** The worktree to display */
worktree: WorktreeInfo;
/** Whether this worktree is currently selected */
isSelected: boolean;
/** Whether this worktree has running features/processes */
isRunning: boolean;
/** Number of cards associated with this worktree's branch */
cardCount?: number;
/** Whether the dev server is running for this worktree */
devServerRunning?: boolean;
/** Dev server information if running */
devServerInfo?: DevServerInfo;
/** Whether auto-mode is running for this worktree */
isAutoModeRunning?: boolean;
/** Whether tests are running for this worktree */
isTestRunning?: boolean;
/** Test session info for this worktree */
testSessionInfo?: TestSessionInfo;
/** Callback when the worktree is selected */
onSelect: () => void;
}
/**
* A dropdown menu item component for displaying an individual worktree entry.
*
* Features:
* - Selection indicator (checkmark when selected)
* - Running status indicator (spinner)
* - Branch name with tooltip for long names
* - Main branch badge
* - Dev server status indicator
* - Auto mode indicator
* - Test status indicator
* - Card count badge
* - Uncommitted changes indicator
* - PR status badge
*/
export function WorktreeDropdownItem({
worktree,
isSelected,
isRunning,
cardCount,
devServerRunning,
devServerInfo,
isAutoModeRunning = false,
isTestRunning = false,
testSessionInfo,
onSelect,
}: WorktreeDropdownItemProps) {
const { hasChanges, changedFilesCount, pr } = worktree;
// Truncate long branch names using shared utility
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
worktree.branch,
MAX_ITEM_BRANCH_NAME_LENGTH
);
const branchNameElement = (
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{truncatedBranch}
</span>
);
return (
<DropdownMenuItem
onSelect={onSelect}
className={cn('flex items-center gap-2 cursor-pointer pr-2', isSelected && 'bg-accent')}
aria-current={isSelected ? 'true' : undefined}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Selection indicator */}
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{/* Running indicator */}
{isRunning && <Spinner size="xs" className="shrink-0" />}
{/* Branch name with optional tooltip */}
{isBranchNameTruncated ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{worktree.branch}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
branchNameElement
)}
{/* Main badge */}
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
{/* Right side indicators - ordered consistently with dropdown trigger */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Card count badge */}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{/* Uncommitted changes indicator */}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
getChangesBadgeStyles()
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
title={`Dev server running on port ${devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{isTestRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!isTestRunning && testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4',
getTestStatusStyles(testSessionInfo.status)
)}
title={`Last test: ${testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{isAutoModeRunning && (
<span className="flex items-center justify-center h-4 px-0.5" title="Auto Mode Running">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR indicator */}
{pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border',
getPRBadgeStyles(pr.state)
)}
title={`PR #${pr.number}: ${pr.title}`}
>
<GitPullRequest className="w-2.5 h-2.5" />#{pr.number}
</span>
)}
</div>
</DropdownMenuItem>
);
}

View File

@@ -0,0 +1,481 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
GitBranch,
ChevronDown,
CircleDot,
Globe,
GitPullRequest,
FlaskConical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type {
WorktreeInfo,
BranchInfo,
DevServerInfo,
PRInfo,
GitRepoStatus,
TestSessionInfo,
} from '../types';
import { WorktreeDropdownItem } from './worktree-dropdown-item';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getTestStatusStyles,
} from './worktree-indicator-utils';
export interface WorktreeDropdownProps {
/** List of all worktrees to display in the dropdown */
worktrees: WorktreeInfo[];
/** Function to check if a worktree is currently selected */
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
/** Function to check if a worktree has running features/processes */
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
/** Whether worktree activation is in progress */
isActivating: boolean;
/** Map of branch names to card counts */
branchCardCounts?: Record<string, number>;
/** Function to check if dev server is running for a worktree */
isDevServerRunning: (worktree: WorktreeInfo) => boolean;
/** Function to get dev server info for a worktree */
getDevServerInfo: (worktree: WorktreeInfo) => DevServerInfo | undefined;
/** Function to check if auto-mode is running for a worktree */
isAutoModeRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to check if tests are running for a worktree */
isTestRunningForWorktree: (worktree: WorktreeInfo) => boolean;
/** Function to get test session info for a worktree */
getTestSessionInfo: (worktree: WorktreeInfo) => TestSessionInfo | undefined;
/** Callback when a worktree is selected */
onSelectWorktree: (worktree: WorktreeInfo) => void;
// Branch switching props
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
onBranchDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onBranchFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
// Action dropdown props
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
hasTestCommand: boolean;
isStartingTests: boolean;
hasInitScript: boolean;
onActionsDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode: (worktree: WorktreeInfo) => void;
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
}
/**
* Maximum characters for branch name before truncation in the dropdown trigger.
* Set to 24 to keep the trigger compact while showing enough context for identification.
*/
const MAX_TRIGGER_BRANCH_NAME_LENGTH = 24;
/**
* A dropdown component for displaying and switching between worktrees.
* Used when there are 3+ worktrees to avoid horizontal tab wrapping.
*
* Features:
* - Compact dropdown trigger showing current worktree with indicators
* - Grouped display (main branch + worktrees)
* - Full status indicators (PR, dev server, auto mode, changes)
* - Branch switch dropdown integration
* - Actions dropdown integration
* - Tooltip for truncated branch names
*/
export function WorktreeDropdown({
worktrees,
isWorktreeSelected,
hasRunningFeatures,
isActivating,
branchCardCounts,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
isTestRunningForWorktree,
getTestSessionInfo,
onSelectWorktree,
// Branch switching props
branches,
filteredBranches,
branchFilter,
isLoadingBranches,
isSwitching,
onBranchDropdownOpenChange,
onBranchFilterChange,
onSwitchBranch,
onCreateBranch,
// Action dropdown props
isPulling,
isPushing,
isStartingDevServer,
aheadCount,
behindCount,
hasRemoteBranch,
gitRepoStatus,
hasTestCommand,
isStartingTests,
hasInitScript,
onActionsDropdownOpenChange,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onStartTests,
onStopTests,
onViewTestLogs,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select worktree';
const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName(
displayBranch,
MAX_TRIGGER_BRANCH_NAME_LENGTH
);
// Separate main worktree from others for grouping
const mainWorktree = worktrees.find((w) => w.isMain);
const otherWorktrees = worktrees.filter((w) => !w.isMain);
// Get status info for selected worktree - memoized to prevent unnecessary recalculations
const selectedStatus = useMemo(() => {
if (!selectedWorktree) {
return {
devServerRunning: false,
devServerInfo: undefined,
autoModeRunning: false,
isRunning: false,
testRunning: false,
testSessionInfo: undefined,
};
}
return {
devServerRunning: isDevServerRunning(selectedWorktree),
devServerInfo: getDevServerInfo(selectedWorktree),
autoModeRunning: isAutoModeRunningForWorktree(selectedWorktree),
isRunning: hasRunningFeatures(selectedWorktree),
testRunning: isTestRunningForWorktree(selectedWorktree),
testSessionInfo: getTestSessionInfo(selectedWorktree),
};
}, [
selectedWorktree,
isDevServerRunning,
getDevServerInfo,
isAutoModeRunningForWorktree,
hasRunningFeatures,
isTestRunningForWorktree,
getTestSessionInfo,
]);
// Build trigger button with all indicators - memoized for performance
const triggerButton = useMemo(
() => (
<Button
variant="outline"
size="sm"
className={cn(
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
)}
disabled={isActivating}
>
{/* Running/Activating indicator */}
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
{/* Branch icon */}
<GitBranch className="w-3.5 h-3.5 shrink-0" />
{/* Branch name with optional tooltip */}
<span className="truncate max-w-[150px]">{truncatedBranch}</span>
{/* Card count badge */}
{selectedWorktree &&
branchCardCounts?.[selectedWorktree.branch] !== undefined &&
branchCardCounts[selectedWorktree.branch] > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border shrink-0">
{branchCardCounts[selectedWorktree.branch]}
</span>
)}
{/* Uncommitted changes indicator */}
{selectedWorktree?.hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
getChangesBadgeStyles()
)}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{selectedWorktree.changedFilesCount ?? '!'}
</span>
)}
{/* Dev server indicator */}
{selectedStatus.devServerRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
)}
{/* Test running indicator */}
{selectedStatus.testRunning && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-blue-500 shrink-0"
title="Tests Running"
>
<FlaskConical className="w-3 h-3 animate-pulse" />
</span>
)}
{/* Last test result indicator (when not running) */}
{!selectedStatus.testRunning && selectedStatus.testSessionInfo && (
<span
className={cn(
'inline-flex items-center justify-center h-4 w-4 shrink-0',
getTestStatusStyles(selectedStatus.testSessionInfo.status)
)}
title={`Last test: ${selectedStatus.testSessionInfo.status}`}
>
<FlaskConical className="w-3 h-3" />
</span>
)}
{/* Auto mode indicator */}
{selectedStatus.autoModeRunning && (
<span
className="flex items-center justify-center h-4 px-0.5 shrink-0"
title="Auto Mode Running"
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
)}
{/* PR badge */}
{selectedWorktree?.pr && (
<span
className={cn(
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border shrink-0',
getPRBadgeStyles(selectedWorktree.pr.state)
)}
>
<GitPullRequest className="w-2.5 h-2.5" />#{selectedWorktree.pr.number}
</span>
)}
{/* Dropdown chevron */}
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
</Button>
),
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
);
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
const triggerWithTooltip = isBranchNameTruncated ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{displayBranch}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
dropdownTrigger
);
return (
<div className="flex items-center">
<DropdownMenu>
{triggerWithTooltip}
<DropdownMenuContent
align="start"
className="w-80 max-h-96 overflow-y-auto"
aria-label="Worktree selection"
>
{/* Main worktree section */}
{mainWorktree && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Main Branch
</DropdownMenuLabel>
<WorktreeDropdownItem
worktree={mainWorktree}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
cardCount={branchCardCounts?.[mainWorktree.branch]}
devServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
isTestRunning={isTestRunningForWorktree(mainWorktree)}
testSessionInfo={getTestSessionInfo(mainWorktree)}
onSelect={() => onSelectWorktree(mainWorktree)}
/>
</>
)}
{/* Other worktrees section */}
{otherWorktrees.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Worktrees ({otherWorktrees.length})
</DropdownMenuLabel>
<DropdownMenuGroup>
{otherWorktrees.map((worktree) => (
<WorktreeDropdownItem
key={worktree.path}
worktree={worktree}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
cardCount={branchCardCounts?.[worktree.branch]}
devServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
isTestRunning={isTestRunningForWorktree(worktree)}
testSessionInfo={getTestSessionInfo(worktree)}
onSelect={() => onSelectWorktree(worktree)}
/>
))}
</DropdownMenuGroup>
</>
)}
{/* Empty state */}
{worktrees.length === 0 && (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No worktrees available
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Branch switch dropdown for main branch (only when main is selected) */}
{selectedWorktree?.isMain && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={onBranchDropdownOpenChange(selectedWorktree)}
onFilterChange={onBranchFilterChange}
onSwitchBranch={onSwitchBranch}
onCreateBranch={onCreateBranch}
/>
)}
{/* Actions dropdown for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
hasInitScript={hasInitScript}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Shared utility functions for worktree indicator styling and formatting.
* These utilities ensure consistent appearance across WorktreeTab, WorktreeDropdown,
* and WorktreeDropdownItem components.
*/
import type { PRInfo } from '../types';
/**
* Truncates a branch name if it exceeds the maximum length.
* @param branchName - The full branch name
* @param maxLength - Maximum characters before truncation
* @returns Object with truncated name and whether truncation occurred
*/
export function truncateBranchName(
branchName: string,
maxLength: number
): { truncated: string; isTruncated: boolean } {
const isTruncated = branchName.length > maxLength;
const truncated = isTruncated ? `${branchName.slice(0, maxLength)}...` : branchName;
return { truncated, isTruncated };
}
/**
* Returns the appropriate CSS classes for a PR badge based on PR state.
* @param state - The PR state (OPEN, MERGED, or CLOSED)
* @returns CSS class string for the badge
*/
export function getPRBadgeStyles(state: PRInfo['state']): string {
switch (state) {
case 'OPEN':
return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30';
case 'MERGED':
return 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30';
case 'CLOSED':
default:
return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30';
}
}
/**
* Returns the CSS classes for the uncommitted changes badge.
* This is a constant style used across all worktree components.
*/
export function getChangesBadgeStyles(): string {
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
}
/** Possible test session status values */
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';
/**
* Returns the CSS classes for a test status indicator based on test result.
* @param status - The test session status
* @returns CSS class string for the indicator color
*/
export function getTestStatusStyles(status: TestStatus): string {
switch (status) {
case 'passed':
return 'text-green-500';
case 'failed':
return 'text-red-500';
case 'running':
return 'text-blue-500';
case 'pending':
case 'cancelled':
default:
return 'text-muted-foreground';
}
}

View File

@@ -260,8 +260,10 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -327,8 +329,10 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{isRunning && <Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />}
{isActivating && !isRunning && (
<Spinner size="xs" variant={isSelected ? 'foreground' : 'primary'} />
)}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">

View File

@@ -28,6 +28,7 @@ import {
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
@@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
const WORKTREE_DROPDOWN_THRESHOLD = 3;
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -712,12 +716,112 @@ export function WorktreePanel({
);
}
// Desktop view: full tabs layout
// Use dropdown layout when worktree count meets or exceeds the threshold
const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD;
// Desktop view: full tabs layout or dropdown layout depending on worktree count
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<span className="text-sm text-muted-foreground mr-2">
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
</span>
{/* Dropdown layout for 3+ worktrees */}
{useDropdownLayout ? (
<>
<WorktreeDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
isDevServerRunning={isDevServerRunning}
getDevServerInfo={getDevServerInfo}
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
isTestRunningForWorktree={isTestRunningForWorktree}
getTestSessionInfo={getTestSessionInfo}
onSelectWorktree={handleSelectWorktree}
// Branch switching props
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
// Action dropdown props
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
/>
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
</>
) : (
/* Standard tabs layout for 1-2 worktrees */
<>
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
@@ -782,7 +886,7 @@ export function WorktreePanel({
)}
</div>
{/* Worktrees section - only show if enabled */}
{/* Worktrees section - only show if enabled and not using dropdown layout */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
@@ -883,6 +987,8 @@ export function WorktreePanel({
</div>
</>
)}
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog

View File

@@ -572,7 +572,7 @@ export function InterviewView() {
>
{isGenerating ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Creating...
</>
) : (

View File

@@ -448,7 +448,7 @@ export function LoginView() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Authenticating...
</>
) : (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Palette, Moon, Sun, Type, PanelLeft, Columns2 } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Palette, Moon, Sun, Type, Sparkles, PanelLeft, Columns2 } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import {
UI_SANS_FONT_OPTIONS,
@@ -24,6 +25,8 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
fontFamilyMono,
setFontSans,
setFontMono,
disableSplashScreen,
setDisableSplashScreen,
sidebarStyle,
setSidebarStyle,
} = useAppStore();
@@ -198,6 +201,30 @@ export function AppearanceSection({ effectiveTheme, onThemeChange }: AppearanceS
</div>
</div>
{/* Splash Screen Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-4 h-4 text-muted-foreground" />
<Label className="text-foreground font-medium">Startup</Label>
</div>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor="disable-splash-screen" className="text-sm">
Disable Splash Screen
</Label>
<p className="text-xs text-muted-foreground">
Skip the animated splash screen when the app starts
</p>
</div>
<Switch
id="disable-splash-screen"
checked={disableSplashScreen}
onCheckedChange={setDisableSplashScreen}
/>
</div>
</div>
{/* Sidebar Style Section */}
<div className="space-y-4 pt-6 border-t border-border/50">
<div className="flex items-center gap-2 mb-4">

View File

@@ -60,7 +60,7 @@ export function CliInstallationCard({
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (

View File

@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isSavingApiKey ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Saving...
</>
) : (

View File

@@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
>
{isSavingApiKey ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Saving...
</>
) : (

View File

@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -329,7 +329,7 @@ function ClaudeContent() {
>
{isInstalling ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Installing...
</>
) : (
@@ -424,7 +424,11 @@ function ClaudeContent() {
disabled={isSavingApiKey || !apiKey.trim()}
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
>
{isSavingApiKey ? <Spinner size="sm" /> : 'Save API Key'}
{isSavingApiKey ? (
<Spinner size="sm" variant="foreground" />
) : (
'Save API Key'
)}
</Button>
{hasApiKey && (
<Button
@@ -661,7 +665,7 @@ function CursorContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -918,7 +922,7 @@ function CodexContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -961,7 +965,7 @@ function CodexContent() {
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
@@ -1194,7 +1198,7 @@ function OpencodeContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -1466,7 +1470,7 @@ function GeminiContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (
@@ -1509,7 +1513,7 @@ function GeminiContent() {
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
{isSaving ? <Spinner size="sm" variant="foreground" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
@@ -1745,7 +1749,7 @@ function CopilotContent() {
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
<Spinner size="sm" variant="foreground" className="mr-2" />
Waiting for login...
</>
) : (

View File

@@ -22,6 +22,59 @@ import { useEventRecencyStore } from './use-event-recency';
const PROGRESS_DEBOUNCE_WAIT = 150;
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
/**
* Events that should invalidate the feature list (features.all query)
* Note: pipeline_step_started is included to ensure Kanban board immediately reflects
* feature moving to custom pipeline columns (fixes GitHub issue #668)
*/
const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_complete',
'auto_mode_error',
'plan_approval_required',
'plan_approved',
'plan_rejected',
'pipeline_step_started',
'pipeline_step_complete',
];
/**
* Events that should invalidate a specific feature (features.single query)
* Note: pipeline_step_started is NOT included here because it already invalidates
* features.all() above, which also invalidates child queries (features.single)
*/
const SINGLE_FEATURE_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start',
'auto_mode_phase',
'auto_mode_phase_complete',
'auto_mode_task_status',
'auto_mode_summary',
];
/**
* Events that should invalidate running agents status
*/
const RUNNING_AGENTS_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start',
'auto_mode_feature_complete',
'auto_mode_error',
'auto_mode_resuming_features',
];
/**
* Events that signal a feature is done and debounce cleanup should occur
*/
const FEATURE_CLEANUP_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_complete',
'auto_mode_error',
];
/**
* Type guard to check if an event has a featureId property
*/
function hasFeatureId(event: AutoModeEvent): event is AutoModeEvent & { featureId: string } {
return 'featureId' in event && typeof event.featureId === 'string';
}
/**
* Creates a unique key for per-feature debounce tracking
*/
@@ -115,42 +168,22 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// This allows polling to be disabled when WebSocket events are flowing
recordGlobalEvent();
// Invalidate features when agent completes, errors, or receives plan approval
if (
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'plan_approval_required' ||
event.type === 'plan_approved' ||
event.type === 'plan_rejected' ||
event.type === 'pipeline_step_complete'
) {
// Invalidate feature list for lifecycle events
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProjectPath),
});
}
// Invalidate running agents on any status change
if (
event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'auto_mode_resuming_features'
) {
// Invalidate running agents on status changes
if (RUNNING_AGENTS_INVALIDATION_EVENTS.includes(event.type)) {
queryClient.invalidateQueries({
queryKey: queryKeys.runningAgents.all(),
});
}
// Invalidate specific feature when it starts, has phase changes, or task status updates
if (
(event.type === 'auto_mode_feature_start' ||
event.type === 'auto_mode_phase' ||
event.type === 'auto_mode_phase_complete' ||
event.type === 'auto_mode_task_status' ||
event.type === 'auto_mode_summary' ||
event.type === 'pipeline_step_started') &&
'featureId' in event
) {
// Invalidate specific feature for phase changes and task status updates
if (SINGLE_FEATURE_INVALIDATION_EVENTS.includes(event.type) && hasFeatureId(event)) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
});
@@ -158,23 +191,19 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// Invalidate agent output during progress updates (DEBOUNCED)
// Uses per-feature debouncing to batch rapid progress events during streaming
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
if (event.type === 'auto_mode_progress' && hasFeatureId(event)) {
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
debouncedInvalidation();
}
// Clean up debounced functions when feature completes or errors
// This ensures we flush any pending invalidations and free memory
if (
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
'featureId' in event &&
event.featureId
) {
if (FEATURE_CLEANUP_EVENTS.includes(event.type) && hasFeatureId(event)) {
cleanupFeatureDebounce(event.featureId);
}
// Invalidate worktree queries when feature completes (may have created worktree)
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
if (event.type === 'auto_mode_feature_complete' && hasFeatureId(event)) {
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(currentProjectPath),
});

View File

@@ -181,6 +181,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
muteDoneSound: state.muteDoneSound as boolean,
disableSplashScreen: state.disableSplashScreen as boolean,
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
validationModel: state.validationModel as GlobalSettings['validationModel'],
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
@@ -713,6 +714,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
model: 'claude-opus',
},
muteDoneSound: settings.muteDoneSound ?? false,
disableSplashScreen: settings.disableSplashScreen ?? false,
serverLogLevel: settings.serverLogLevel ?? 'info',
enableRequestLogging: settings.enableRequestLogging ?? true,
showQueryDevtools: settings.showQueryDevtools ?? true,
@@ -800,6 +802,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
muteDoneSound: state.muteDoneSound,
disableSplashScreen: state.disableSplashScreen,
serverLogLevel: state.serverLogLevel,
enableRequestLogging: state.enableRequestLogging,
enhancementModel: state.enhancementModel,

View File

@@ -66,6 +66,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultRequirePlanApproval',
'defaultFeatureModel',
'muteDoneSound',
'disableSplashScreen',
'serverLogLevel',
'enableRequestLogging',
'showQueryDevtools',
@@ -714,6 +715,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
: { model: 'claude-opus' },
muteDoneSound: serverSettings.muteDoneSound,
disableSplashScreen: serverSettings.disableSplashScreen ?? false,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel,

View File

@@ -672,6 +672,9 @@ export interface AppState {
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Splash Screen Settings
disableSplashScreen: boolean; // When true, skip showing the splash screen overlay on startup
// Server Log Level Settings
serverLogLevel: ServerLogLevel; // Log level for the API server (error, warn, info, debug)
enableRequestLogging: boolean; // Enable HTTP request logging (Morgan)
@@ -1172,6 +1175,9 @@ export interface AppActions {
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// Splash Screen actions
setDisableSplashScreen: (disabled: boolean) => void;
// Server Log Level actions
setServerLogLevel: (level: ServerLogLevel) => void;
setEnableRequestLogging: (enabled: boolean) => void;
@@ -1493,6 +1499,7 @@ const initialState: AppState = {
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
disableSplashScreen: false, // Default to showing splash screen
serverLogLevel: 'info', // Default to info level for server logs
enableRequestLogging: true, // Default to enabled for HTTP request logging
showQueryDevtools: true, // Default to enabled (only shown in dev mode anyway)
@@ -2626,6 +2633,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Splash Screen actions
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),

View File

@@ -32,6 +32,7 @@ import {
migrateModelId,
type PhaseModelEntry,
type ThinkingLevel,
type ReasoningEffort,
} from '@automaker/types';
// Pattern definitions for Codex/OpenAI models
@@ -162,8 +163,10 @@ export function getEffectiveModel(
export interface ResolvedPhaseModel {
/** Resolved model string (full model ID) */
model: string;
/** Optional thinking level for extended thinking */
/** Optional thinking level for extended thinking (Claude models) */
thinkingLevel?: ThinkingLevel;
/** Optional reasoning effort for timeout calculation (Codex models) */
reasoningEffort?: ReasoningEffort;
/** Provider ID if using a ClaudeCompatibleProvider */
providerId?: string;
}
@@ -205,6 +208,7 @@ export function resolvePhaseModel(
return {
model: resolveModelString(undefined, defaultModel),
thinkingLevel: undefined,
reasoningEffort: undefined,
};
}
@@ -214,12 +218,13 @@ export function resolvePhaseModel(
return {
model: resolveModelString(phaseModel, defaultModel),
thinkingLevel: undefined,
reasoningEffort: undefined,
};
}
// Handle new PhaseModelEntry object format
console.log(
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"`
`[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", reasoningEffort="${phaseModel.reasoningEffort}", providerId="${phaseModel.providerId}"`
);
// If providerId is set, pass through the model string unchanged
@@ -231,6 +236,7 @@ export function resolvePhaseModel(
return {
model: phaseModel.model, // Pass through unchanged
thinkingLevel: phaseModel.thinkingLevel,
reasoningEffort: phaseModel.reasoningEffort,
providerId: phaseModel.providerId,
};
}
@@ -239,5 +245,6 @@ export function resolvePhaseModel(
return {
model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel,
reasoningEffort: phaseModel.reasoningEffort,
};
}

View File

@@ -274,6 +274,7 @@ export {
getBareModelId,
normalizeModelString,
validateBareModelId,
supportsStructuredOutput,
} from './provider-utils.js';
// Model migration utilities

View File

@@ -7,7 +7,7 @@
*/
import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
import { GEMINI_MODEL_MAP } from './gemini-models.js';
@@ -345,6 +345,44 @@ export function normalizeModelString(model: string | undefined | null): string {
return model;
}
/**
* Check if a model supports structured output (JSON schema)
*
* Structured output is a feature that allows the model to return responses
* conforming to a JSON schema. Currently supported by:
* - Claude models (native Anthropic API support)
* - Codex/OpenAI models (via response_format with json_schema)
*
* Models that do NOT support structured output:
* - Cursor models (uses different API format)
* - OpenCode models (various backend providers)
* - Gemini models (different API)
* - Copilot models (proxy to various backends)
*
* @param model - Model string to check
* @returns true if the model supports structured output
*
* @example
* supportsStructuredOutput('sonnet') // true (Claude)
* supportsStructuredOutput('claude-sonnet-4-20250514') // true (Claude)
* supportsStructuredOutput('codex-gpt-5.2') // true (Codex/OpenAI)
* supportsStructuredOutput('cursor-auto') // false
* supportsStructuredOutput('gemini-2.5-pro') // false
*/
export function supportsStructuredOutput(model: string | undefined | null): boolean {
// Exclude proxy providers first - they may have Claude/Codex in the model name
// but route through different APIs that don't support structured output
if (
isCursorModel(model) ||
isGeminiModel(model) ||
isOpencodeModel(model) ||
isCopilotModel(model)
) {
return false;
}
return isClaudeModel(model) || isCodexModel(model);
}
/**
* Validate that a model ID does not contain a provider prefix
*

View File

@@ -873,6 +873,10 @@ export interface GlobalSettings {
/** Mute completion notification sound */
muteDoneSound: boolean;
// Splash Screen
/** Disable the splash screen overlay on app startup */
disableSplashScreen: boolean;
// Server Logging Preferences
/** Log level for the API server (error, warn, info, debug). Default: info */
serverLogLevel?: ServerLogLevel;
@@ -1334,6 +1338,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultRequirePlanApproval: false,
defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
muteDoneSound: false,
disableSplashScreen: false,
serverLogLevel: 'info',
enableRequestLogging: true,
showQueryDevtools: true,