Merge remote-tracking branch 'upstream/v0.13.0rc' into feat/react-query

# Conflicts:
#	apps/ui/src/components/views/board-view.tsx
#	apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
#	apps/ui/src/components/views/board-view/hooks/use-board-features.ts
#	apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
#	apps/ui/src/hooks/use-project-settings-loader.ts
This commit is contained in:
DhanushSantosh
2026-01-20 19:19:21 +05:30
76 changed files with 5995 additions and 492 deletions

View File

@@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
/**
* Check if an environment variable is set to 'true'
*/
function isEnvTrue(envVar: string | undefined): boolean {
return envVar === 'true';
}
// Session store - persisted to file for survival across server restarts
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
@@ -134,8 +141,8 @@ const API_KEY = ensureApiKey();
const BOX_CONTENT_WIDTH = 67;
// Print API key to console for web mode users (unless suppressed for production logging)
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
// Build box lines with exact padding
@@ -375,6 +382,12 @@ function checkAuthentication(
* 5. Session cookie (for web mode)
*/
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Allow disabling auth for local/trusted networks
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
next();
return;
}
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -420,9 +433,10 @@ export function isAuthEnabled(): boolean {
* Get authentication status for health endpoint
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
return {
enabled: true,
method: 'api_key_or_session',
enabled: !disabled,
method: disabled ? 'disabled' : 'api_key_or_session',
};
}
@@ -430,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
const result = checkAuthentication(
req.headers as Record<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -447,5 +462,6 @@ export function checkRawAuthentication(
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

@@ -5,7 +5,12 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
import type {
MCPServerConfig,
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
} from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
@@ -345,3 +350,80 @@ export async function getCustomSubagents(
return Object.keys(merged).length > 0 ? merged : undefined;
}
/** Result from getActiveClaudeApiProfile */
export interface ActiveClaudeApiProfileResult {
/** The active profile, or undefined if using direct Anthropic API */
profile: ClaudeApiProfile | undefined;
/** Credentials for resolving 'credentials' apiKeySource */
credentials: import('@automaker/types').Credentials | undefined;
}
/**
* Get the active Claude API profile and credentials from settings.
* Checks project settings first for per-project overrides, then falls back to global settings.
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @param projectPath - Optional project path for per-project override
* @returns Promise resolving to object with profile and credentials
*/
export async function getActiveClaudeApiProfile(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]',
projectPath?: string
): Promise<ActiveClaudeApiProfileResult> {
if (!settingsService) {
return { profile: undefined, credentials: undefined };
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const credentials = await settingsService.getCredentials();
const profiles = globalSettings.claudeApiProfiles || [];
// Check for project-level override first
let activeProfileId: string | null | undefined;
let isProjectOverride = false;
if (projectPath) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
// undefined = use global, null = explicit no profile, string = specific profile
if (projectSettings.activeClaudeApiProfileId !== undefined) {
activeProfileId = projectSettings.activeClaudeApiProfileId;
isProjectOverride = true;
}
}
// Fall back to global if project doesn't specify
if (activeProfileId === undefined && !isProjectOverride) {
activeProfileId = globalSettings.activeClaudeApiProfileId;
}
// No active profile selected - use direct Anthropic API
if (!activeProfileId) {
if (isProjectOverride && activeProfileId === null) {
logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
}
return { profile: undefined, credentials };
}
// Find the active profile by ID
const activeProfile = profiles.find((p) => p.id === activeProfileId);
if (activeProfile) {
const overrideSuffix = isProjectOverride ? ' (project override)' : '';
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
return { profile: activeProfile, credentials };
} else {
logger.warn(
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
);
return { profile: undefined, credentials };
}
} catch (error) {
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
return { profile: undefined, credentials: undefined };
}
}

View File

@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
import {
getThinkingTokenBudget,
validateBareModelId,
type ClaudeApiProfile,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
@@ -21,9 +26,19 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
@@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
/**
* Build environment for the SDK with only explicitly allowed variables
* Build environment for the SDK with only explicitly allowed variables.
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
* When no profile is provided, uses direct Anthropic API settings from process.env.
*
* @param profile - Optional Claude API profile for alternative endpoint configuration
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
*/
function buildEnv(): Record<string, string | undefined> {
function buildEnv(
profile?: ClaudeApiProfile,
credentials?: Credentials
): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
if (profile) {
// Use profile configuration (clean switch - don't inherit non-system vars from process.env)
logger.debug('Building environment from Claude API profile:', {
name: profile.name,
apiKeySource: profile.apiKeySource ?? 'inline',
});
// Resolve API key based on source strategy
let apiKey: string | undefined;
const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat
switch (source) {
case 'inline':
apiKey = profile.apiKey;
break;
case 'env':
apiKey = process.env.ANTHROPIC_API_KEY;
break;
case 'credentials':
apiKey = credentials?.apiKeys?.anthropic;
break;
}
// Warn if no API key found
if (!apiKey) {
logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`);
}
// Authentication
if (profile.useAuthToken) {
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
} else {
env['ANTHROPIC_API_KEY'] = apiKey;
}
// Endpoint configuration
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
if (profile.timeoutMs) {
env['API_TIMEOUT_MS'] = String(profile.timeoutMs);
}
// Model mappings
if (profile.modelMappings?.haiku) {
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
}
if (profile.modelMappings?.sonnet) {
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
}
if (profile.modelMappings?.opus) {
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
}
// Traffic control
if (profile.disableNonessentialTraffic) {
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
}
} else {
// Use direct Anthropic API - pass through credentials or environment variables
// This supports:
// 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
// 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
//
// Priority: credentials file (UI settings) -> environment variable
// Note: Only auth and endpoint vars are passed. Model mappings and traffic
// control are NOT passed (those require a profile for explicit configuration).
if (credentials?.apiKeys?.anthropic) {
env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
} else if (process.env.ANTHROPIC_API_KEY) {
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
}
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
// unless it was explicitly set in process.env (rare edge case).
if (process.env.ANTHROPIC_AUTH_TOKEN) {
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
}
// Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
if (process.env.ANTHROPIC_BASE_URL) {
env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
}
}
// Always add system vars from process.env
for (const key of SYSTEM_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
return env;
}
@@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
claudeApiProfile,
credentials,
} = options;
// Convert thinking level to token budget
@@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// When a profile is active, uses profile settings (clean switch)
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth)
env: buildEnv(claudeApiProfile, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation

View File

@@ -25,7 +25,6 @@ import type {
InstallationStatus,
ContentBlock,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
@@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider {
args.push('--format', 'json');
// Handle model selection
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
if (options.model) {
const model = stripProviderPrefix(options.model);
args.push('--model', model);
// Strip opencode- prefix if present, then ensure slash format
const model = options.model.startsWith('opencode-')
? options.model.slice('opencode-'.length)
: options.model;
// If model has slash, it's already provider/model format; otherwise prepend opencode/
const cliModel = model.includes('/') ? model : `opencode/${model}`;
args.push('--model', cliModel);
}
// Note: OpenCode reads from stdin automatically when input is piped

View File

@@ -20,6 +20,8 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
/** Active Claude API profile for alternative endpoint configuration */
claudeApiProfile?: ClaudeApiProfile;
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */
credentials?: Credentials;
}
/**
@@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -123,6 +127,13 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[FeatureGeneration]',
projectPath
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt,
@@ -134,6 +145,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
logger.info('Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecRegeneration]',
projectPath
);
let responseText = '';
let structuredOutput: SpecOutput | null = null;
@@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
@@ -157,6 +160,13 @@ export async function syncSpec(
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[SpecSync]',
projectPath
);
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
@@ -185,6 +195,8 @@ Return ONLY this JSON format, no other text:
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStartHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, maxConcurrency } = req.body as {
const { projectPath, branchName, maxConcurrency } = req.body as {
projectPath: string;
branchName?: string | null;
maxConcurrency?: number;
};
@@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) {
return;
}
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if already running
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({
success: true,
message: 'Auto mode is already running for this project',
message: `Auto mode is already running for ${worktreeDesc}`,
alreadyRunning: true,
branchName: normalizedBranchName,
});
return;
}
// Start the auto loop for this project
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
// Start the auto loop for this project/worktree
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
projectPath,
normalizedBranchName,
maxConcurrency
);
logger.info(
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
);
res.json({
success: true,
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
branchName: normalizedBranchName,
});
} catch (error) {
logError(error, 'Start auto mode failed');

View File

@@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath?: string };
const { projectPath, branchName } = req.body as {
projectPath?: string;
branchName?: string | null;
};
// If projectPath is provided, return per-project status
// If projectPath is provided, return per-project/worktree status
if (projectPath) {
const projectStatus = autoModeService.getStatusForProject(projectPath);
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const projectStatus = autoModeService.getStatusForProject(
projectPath,
normalizedBranchName
);
res.json({
success: true,
isRunning: projectStatus.runningCount > 0,
@@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
runningCount: projectStatus.runningCount,
maxConcurrency: projectStatus.maxConcurrency,
projectPath,
branchName: normalizedBranchName,
});
return;
}
@@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) {
// Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
const activeProjects = autoModeService.getActiveAutoLoopProjects();
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
res.json({
success: true,
...status,
activeAutoLoopProjects: activeProjects,
activeAutoLoopWorktrees: activeWorktrees,
});
} catch (error) {
logError(error, 'Get status failed');

View File

@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
export function createStopHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as {
const { projectPath, branchName } = req.body as {
projectPath: string;
branchName?: string | null;
};
if (!projectPath) {
@@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) {
return;
}
// Normalize branchName: undefined becomes null
const normalizedBranchName = branchName ?? null;
const worktreeDesc = normalizedBranchName
? `worktree ${normalizedBranchName}`
: 'main worktree';
// Check if running
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
res.json({
success: true,
message: 'Auto mode is not running for this project',
message: `Auto mode is not running for ${worktreeDesc}`,
wasRunning: false,
branchName: normalizedBranchName,
});
return;
}
// Stop the auto loop for this project
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
// Stop the auto loop for this project/worktree
const runningCount = await autoModeService.stopAutoLoopForProject(
projectPath,
normalizedBranchName
);
logger.info(
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
);
res.json({
success: true,
message: 'Auto mode stopped',
runningFeaturesCount: runningCount,
branchName: normalizedBranchName,
});
} catch (error) {
logError(error, 'Stop auto mode failed');

View File

@@ -25,7 +25,11 @@ import {
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -161,6 +165,13 @@ ${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
}
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[BacklogPlan]',
projectPath
);
// Execute the query
const stream = provider.executeQuery({
prompt: finalPrompt,
@@ -173,6 +184,8 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let responseText = '';

View File

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -165,6 +166,13 @@ ${contentToAnalyze}`;
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeFile]',
cwd
);
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
prompt,
@@ -175,6 +183,8 @@ ${contentToAnalyze}`;
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const description = result.text;

View File

@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -284,6 +285,13 @@ export function createDescribeImageHandler(
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[DescribeImage]',
cwd
);
// Build the instruction text from centralized prompts
const instructionText = prompts.contextDescription.describeImagePrompt;
@@ -325,6 +333,8 @@ export function createDescribeImageHandler(
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);

View File

@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -33,6 +36,8 @@ interface EnhanceRequestBody {
model?: string;
/** Optional thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional project path for per-project Claude API profile */
projectPath?: string;
}
/**
@@ -62,7 +67,7 @@ export function createEnhanceHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { originalText, enhancementMode, model, thinkingLevel } =
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
req.body as EnhanceRequestBody;
// Validate required fields
@@ -126,6 +131,14 @@ export function createEnhanceHandler(
logger.debug(`Using model: ${resolvedModel}`);
// Get active Claude API profile for alternative endpoint configuration
// Uses project-specific profile if projectPath provided, otherwise global
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[EnhancePrompt]',
projectPath
);
// Use simpleQuery - provider abstraction handles routing to correct provider
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
@@ -137,6 +150,8 @@ export function createEnhanceHandler(
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const enhancedText = result.text;

View File

@@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateTitle');
interface GenerateTitleRequestBody {
description: string;
projectPath?: string;
}
interface GenerateTitleSuccessResponse {
@@ -33,7 +37,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description } = req.body as GenerateTitleRequestBody;
const { description, projectPath } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {
@@ -60,6 +64,14 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
// Get active Claude API profile for alternative endpoint configuration
// Uses project-specific profile if projectPath provided, otherwise global
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateTitle]',
projectPath
);
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
@@ -69,6 +81,8 @@ export function createGenerateTitleHandler(
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const title = result.text;

View File

@@ -34,7 +34,11 @@ import {
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getActiveClaudeApiProfile,
} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -43,7 +47,6 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -166,6 +169,13 @@ ${basePrompt}`;
logger.info(`Using model: ${model}`);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[IssueValidation]',
projectPath
);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
@@ -177,6 +187,8 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getActiveClaudeApiProfile,
} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
@@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`;
logger.info('[Suggestions] Using model:', model);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[Suggestions]',
projectPath
);
let responseText = '';
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
@@ -223,6 +234,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',

View File

@@ -48,6 +48,7 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -148,5 +149,13 @@ export function createWorktreeRoutes(
createRunInitScriptHandler(events)
);
// Discard changes route
router.post(
'/discard-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createDiscardChangesHandler()
);
return router;
}

View File

@@ -39,7 +39,10 @@ export function createDiffsHandler() {
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
// Check if worktree exists

View File

@@ -0,0 +1,112 @@
/**
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
*
* This performs a destructive operation that:
* 1. Resets staged changes (git reset HEAD)
* 2. Discards modified tracked files (git checkout .)
* 3. Removes untracked files and directories (git clean -fd)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Check for uncommitted changes first
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
discarded: false,
message: 'No changes to discard',
},
});
return;
}
// Count the files that will be affected
const lines = status.trim().split('\n').filter(Boolean);
const fileCount = lines.length;
// Get branch name before discarding
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Discard all changes:
// 1. Reset any staged changes
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there's nothing staged
});
// 2. Discard changes in tracked files
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no tracked changes
});
// 3. Remove untracked files and directories
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no untracked files
});
// Verify all changes were discarded
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
if (finalStatus.trim()) {
// Some changes couldn't be discarded (possibly ignored files or permission issues)
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount - remainingCount,
filesRemaining: remainingCount,
branch: branchName,
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
},
});
} else {
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: fileCount,
filesRemaining: 0,
branch: branchName,
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
},
});
}
} catch (error) {
logError(error, 'Discard changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -37,7 +37,10 @@ export function createFileDiffHandler() {
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);

View File

@@ -10,7 +10,6 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
async function extractTextFromStream(
stream: AsyncIterable<{
type: string;
subtype?: string;
result?: string;
message?: {
content?: Array<{ type: string; text?: string }>;
};
}>
): Promise<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
@@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler(
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
settingsService,
'[GenerateCommitMessage]',
worktreePath
);
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
// Get provider for the model type
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
const effectivePrompt = isCursorModel(model)
? `${systemPrompt}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1,
allowedTools: [],
readOnly: true,
});
let responseText = '';
const stream = provider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
responseText = msg.result;
}
message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
// Wrap with timeout to prevent indefinite hangs
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
const message = responseText.trim();
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {

View File

@@ -28,7 +28,10 @@ export function createInfoHandler() {
}
// Check if worktree exists (git worktrees are stored in project directory)
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {

View File

@@ -28,7 +28,10 @@ export function createStatusHandler() {
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
// Sanitize featureId the same way it's sanitized when creating worktrees
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);

View File

@@ -29,6 +29,7 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
getActiveClaudeApiProfile,
} from '../lib/settings-helpers.js';
interface Message {
@@ -274,6 +275,13 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[AgentService]',
effectiveWorkDir
);
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -378,6 +386,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
// Build prompt content with images

File diff suppressed because it is too large Load Diff

View File

@@ -468,10 +468,41 @@ export class ClaudeUsageService {
/**
* Strip ANSI escape codes from text
* Handles CSI, OSC, and other common ANSI sequences
*/
private stripAnsiCodes(text: string): string {
// First strip ANSI sequences (colors, etc) and handle CR
// eslint-disable-next-line no-control-regex
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
let clean = text
// CSI sequences: ESC [ ... (letter or @)
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
// Other ESC sequences: ESC (letter)
.replace(/\x1B[A-Za-z]/g, '')
// Carriage returns: replace with newline to avoid concatenation
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
// Handle backspaces (\x08) by applying them
// If we encounter a backspace, remove the character before it
while (clean.includes('\x08')) {
clean = clean.replace(/[^\x08]\x08/, '');
clean = clean.replace(/^\x08+/, '');
}
// Explicitly strip known "Synchronized Output" and "Window Title" garbage
// even if ESC is missing (seen in some environments)
clean = clean
.replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l
.replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL
.replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence)
// Strip remaining non-printable control characters (except newline \n)
// ASCII 0-8, 11-31, 127
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
return clean;
}
/**
@@ -550,7 +581,7 @@ export class ClaudeUsageService {
sectionLabel: string,
type: string
): { percentage: number; resetTime: string; resetText: string } {
let percentage = 0;
let percentage: number | null = null;
let resetTime = this.getDefaultResetTime(type);
let resetText = '';
@@ -564,7 +595,7 @@ export class ClaudeUsageService {
}
if (sectionIndex === -1) {
return { percentage, resetTime, resetText };
return { percentage: 0, resetTime, resetText };
}
// Look at the lines following the section header (within a window of 5 lines)
@@ -572,7 +603,8 @@ export class ClaudeUsageService {
for (const line of searchWindow) {
// Extract percentage - only take the first match (avoid picking up next section's data)
if (percentage === 0) {
// Use null to track "not found" since 0% is a valid percentage (100% left = 0% used)
if (percentage === null) {
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (percentMatch) {
const value = parseInt(percentMatch[1], 10);
@@ -584,18 +616,31 @@ export class ClaudeUsageService {
// Extract reset time - only take the first match
if (!resetText && line.toLowerCase().includes('reset')) {
resetText = line;
// Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes
const match = line.match(/(Resets?.*)$/i);
// If regex fails despite 'includes', likely a complex string issues - verify match before using line
// Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage
if (match) {
resetText = match[1];
}
}
}
// Parse the reset time if we found one
if (resetText) {
// Clean up resetText: remove percentage info if it was matched on the same line
// e.g. "46%used Resets5:59pm" -> " Resets5:59pm"
resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim();
// Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm")
resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2');
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
}
return { percentage, resetTime, resetText };
return { percentage: percentage ?? 0, resetTime, resetText };
}
/**
@@ -624,7 +669,7 @@ export class ClaudeUsageService {
}
// Try to parse simple time-only format: "Resets 11am" or "Resets 3pm"
const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
@@ -649,8 +694,11 @@ export class ClaudeUsageService {
}
// Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm"
// The regex explicitly matches only valid 3-letter month abbreviations to avoid
// matching words like "Resets" when there's no space separator.
// Optional "resets\s*" prefix handles cases with or without space after "Resets"
const dateMatch = text.match(
/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
/(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const monthName = dateMatch[1];

View File

@@ -57,6 +57,7 @@ interface HookContext {
interface AutoModeEventPayload {
type?: string;
featureId?: string;
featureName?: string;
passes?: boolean;
message?: string;
error?: string;
@@ -152,6 +153,7 @@ export class EventHookService {
// Build context for variable substitution
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,

View File

@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
import { getPromptCustomization } from '../lib/settings-helpers.js';
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -223,6 +223,13 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = {
prompt: message,
model: bareModel,
@@ -232,6 +239,8 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
@@ -678,6 +687,13 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
// Get active Claude API profile for alternative endpoint configuration
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
this.settingsService,
'[IdeationService]',
projectPath
);
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -688,6 +704,8 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);

View File

@@ -41,7 +41,12 @@ import {
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '../types/settings.js';
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
import {
DEFAULT_MAX_CONCURRENCY,
migrateModelId,
migrateCursorModelIds,
migrateOpencodeModelIds,
} from '@automaker/types';
const logger = createLogger('SettingsService');
@@ -166,6 +171,41 @@ export class SettingsService {
needsSave = true;
}
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
// If user has an Anthropic API key in credentials but no profiles, create a
// "Direct Anthropic" profile that references the credentials and set it as active.
if (storedVersion < 5) {
try {
const credentials = await this.getCredentials();
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
const directAnthropicProfile = {
id: `profile-${Date.now()}-direct-anthropic`,
name: 'Direct Anthropic',
baseUrl: 'https://api.anthropic.com',
apiKeySource: 'credentials' as const,
useAuthToken: false,
};
result.claudeApiProfiles = [directAnthropicProfile];
result.activeClaudeApiProfileId = directAnthropicProfile.id;
logger.info(
'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
);
}
} catch (error) {
logger.warn(
'Migration v4->v5: Could not check credentials for auto-profile creation:',
error
);
}
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -372,6 +412,7 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Empty object overwrite guard
if (
@@ -597,6 +638,17 @@ export class SettingsService {
};
}
// Handle activeClaudeApiProfileId special cases:
// - "__USE_GLOBAL__" marker means delete the key (use global setting)
// - null means explicit "Direct Anthropic API"
// - string means specific profile ID
if (
'activeClaudeApiProfileId' in updates &&
updates.activeClaudeApiProfileId === '__USE_GLOBAL__'
) {
delete updated.activeClaudeApiProfileId;
}
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
@@ -682,7 +734,7 @@ export class SettingsService {
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
maxConcurrency: (appState.maxConcurrency as number) || 3,
maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY,
defaultSkipTests:
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
enableDependencyBlocking:

View File

@@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => {
expect(result).toBe('Plain text');
});
it('should strip OSC sequences (window title, etc.)', () => {
const service = new ClaudeUsageService();
// OSC sequence to set window title: ESC ] 0 ; title BEL
const input = '\x1B]0;Claude Code\x07Regular text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Regular text');
});
it('should strip DEC private mode sequences', () => {
const service = new ClaudeUsageService();
// DEC private mode sequences like ESC[?2026h and ESC[?2026l
const input = '\x1B[?2026lClaude Code\x1B[?2026h more text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Claude Code more text');
});
it('should handle complex terminal output with mixed escape sequences', () => {
const service = new ClaudeUsageService();
// Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h"
// This contains OSC (set title) and DEC private mode sequences
const input =
'\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Current session 0%used Resets3am');
});
it('should strip single character escape sequences', () => {
const service = new ClaudeUsageService();
// ESC c is the reset terminal command
const input = '\x1BcReset text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Reset text');
});
it('should remove control characters but preserve newlines and tabs', () => {
const service = new ClaudeUsageService();
// BEL character (\x07) should be stripped, but the word "Bell" is regular text
const input = 'Line 1\nLine 2\tTabbed\x07 with bell';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
// BEL is stripped, newlines and tabs preserved
expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
});
});
describe('parseResetTime', () => {

View File

@@ -147,6 +147,7 @@
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"publish": null,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"directories": {
"output": "release"

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, memo, useCallback } from 'react';
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { toast } from 'sonner';
@@ -6,35 +6,67 @@ import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import {
PROJECT_DARK_THEMES,
PROJECT_LIGHT_THEMES,
THEME_SUBMENU_CONSTANTS,
} from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constant for "use global theme" option
/**
* Constant representing the "use global theme" option.
* An empty string is used to indicate that no project-specific theme is set.
*/
const USE_GLOBAL_THEME = '' as const;
// Constants for z-index values
/**
* Z-index values for context menu layering.
* Ensures proper stacking order when menus overlap.
*/
const Z_INDEX = {
/** Base z-index for the main context menu */
CONTEXT_MENU: 100,
/** Higher z-index for theme submenu to appear above parent menu */
THEME_SUBMENU: 101,
} as const;
// Theme option type - using ThemeMode for type safety
/**
* Represents a selectable theme option in the theme submenu.
* Uses ThemeMode from app-store for type safety.
*/
interface ThemeOption {
/** The theme mode value (e.g., 'dark', 'light', 'dracula') */
value: ThemeMode;
/** Display label for the theme option */
label: string;
/** Lucide icon component to display alongside the label */
icon: LucideIcon;
/** CSS color value for the icon */
color: string;
}
// Reusable theme button component to avoid duplication (DRY principle)
/**
* Props for the ThemeButton component.
* Defines the interface for rendering individual theme selection buttons.
*/
interface ThemeButtonProps {
/** The theme option data to display */
option: ThemeOption;
/** Whether this theme is currently selected */
isSelected: boolean;
/** Handler for pointer enter events (used for preview) */
onPointerEnter: () => void;
/** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */
onClick: () => void;
}
/**
* A reusable button component for individual theme options.
* Implements hover preview and selection functionality.
* Memoized to prevent unnecessary re-renders when parent state changes.
*/
const ThemeButton = memo(function ThemeButton({
option,
isSelected,
@@ -63,17 +95,33 @@ const ThemeButton = memo(function ThemeButton({
);
});
// Reusable theme column component
/**
* Props for the ThemeColumn component.
* Defines the interface for rendering a column of related theme options (e.g., dark or light themes).
*/
interface ThemeColumnProps {
/** Column header title (e.g., "Dark", "Light") */
title: string;
/** Icon to display in the column header */
icon: LucideIcon;
/** Array of theme options to display in this column */
themes: ThemeOption[];
/** Currently selected theme value, or null if using global theme */
selectedTheme: ThemeMode | null;
/** Handler called when user hovers over a theme option for preview */
onPreviewEnter: (value: ThemeMode) => void;
/** Handler called when user stops hovering over a theme option */
onPreviewLeave: (e: React.PointerEvent) => void;
/** Handler called when user clicks to select a theme */
onSelect: (value: ThemeMode) => void;
}
/**
* A reusable column component for displaying themed options.
* Renders a group of related themes (e.g., all dark themes or all light themes)
* with a header and scrollable list of ThemeButton components.
* Memoized to prevent unnecessary re-renders.
*/
const ThemeColumn = memo(function ThemeColumn({
title,
icon: Icon,
@@ -105,13 +153,36 @@ const ThemeColumn = memo(function ThemeColumn({
);
});
/**
* Props for the ProjectContextMenu component.
* Defines the interface for the project right-click context menu.
*/
interface ProjectContextMenuProps {
/** The project this context menu is for */
project: Project;
/** Screen coordinates where the context menu should appear */
position: { x: number; y: number };
/** Callback to close the context menu */
onClose: () => void;
/** Callback when user selects "Edit Name & Icon" option */
onEdit: (project: Project) => void;
}
/**
* A context menu component for project-specific actions.
*
* Provides options for:
* - Editing project name and icon
* - Setting project-specific theme (with live preview on hover)
* - Removing project from the workspace
*
* Features viewport-aware positioning for the theme submenu to prevent
* overflow, and implements delayed hover handling to improve UX when
* navigating between the trigger button and submenu.
*
* @param props - Component props
* @returns The rendered context menu or null if not visible
*/
export function ProjectContextMenu({
project,
position,
@@ -130,9 +201,82 @@ export function ProjectContextMenu({
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
// Handler to open theme submenu and cancel any pending close
const handleThemeMenuEnter = useCallback(() => {
// Cancel any pending close timeout
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setShowThemeSubmenu(true);
}, []);
// Handler to close theme submenu with a small delay
// This prevents the submenu from closing when mouse crosses the gap between trigger and submenu
const handleThemeMenuLeave = useCallback(() => {
// Add a small delay before closing to allow mouse to reach submenu
closeTimeoutRef.current = setTimeout(() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}, 100); // 100ms delay is enough to cross the gap
}, [setPreviewTheme]);
/**
* Calculates theme submenu position to prevent viewport overflow.
*
* This memoized calculation determines the optimal vertical position and maximum
* height for the theme submenu based on the current viewport dimensions and
* the trigger button's position.
*
* @returns Object containing:
* - top: Vertical offset from default position (negative values shift submenu up)
* - maxHeight: Maximum height constraint to prevent overflow with scrolling
*/
const submenuPosition = useMemo(() => {
const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } =
THEME_SUBMENU_CONSTANTS;
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
// Calculate where the submenu's bottom edge would be if positioned normally
const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT;
// Check if submenu would overflow bottom of viewport
const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING;
// If it would overflow, calculate how much to shift it up
if (wouldOverflowBottom) {
// Calculate the offset needed to align submenu bottom with viewport bottom minus padding
const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING);
return {
top: -overflowAmount,
maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2),
};
}
// Default: submenu opens at top of parent (aligned with the theme button)
return {
top: 0,
maxHeight: Math.min(
ESTIMATED_SUBMENU_HEIGHT,
viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING
),
};
}, [position.y]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
useEffect(() => {
const handleClickOutside = (event: globalThis.MouseEvent) => {
// Don't close if a confirmation dialog is open (dialog is in a portal)
@@ -242,11 +386,8 @@ export function ProjectContextMenu({
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
onMouseEnter={handleThemeMenuEnter}
onMouseLeave={handleThemeMenuLeave}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'absolute left-full ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
style={{
zIndex: Z_INDEX.THEME_SUBMENU,
top: `${submenuPosition.top}px`,
}}
data-testid="project-theme-submenu"
onMouseEnter={handleThemeMenuEnter}
onMouseLeave={handleThemeMenuLeave}
>
<div className="p-2">
{/* Use Global Option */}
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
{/* Dynamic max height with scroll for viewport overflow handling */}
<div
className="flex gap-2 overflow-y-auto scrollbar-styled"
style={{
maxHeight: `${Math.max(0, submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT)}px`,
}}
>
<ThemeColumn
title="Dark"
icon={Moon}

View File

@@ -30,17 +30,41 @@ import {
import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, THEME_SUBMENU_CONSTANTS } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
/**
* Props for the ProjectSelectorWithOptions component.
* Defines the interface for the project selector dropdown with additional options menu.
*/
interface ProjectSelectorWithOptionsProps {
/** Whether the sidebar is currently expanded */
sidebarOpen: boolean;
/** Whether the project picker dropdown is currently open */
isProjectPickerOpen: boolean;
/** Callback to control the project picker dropdown open state */
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void;
}
/**
* A project selector component with search, drag-and-drop reordering, and options menu.
*
* Features:
* - Searchable dropdown for quick project switching
* - Drag-and-drop reordering of projects
* - Project-specific theme selection with live preview
* - Project history navigation (previous/next)
* - Option to move project to trash
*
* The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS
* for consistent submenu behavior across the application.
*
* @param props - Component props
* @returns The rendered project selector or null if sidebar is closed or no projects exist
*/
export function ProjectSelectorWithOptions({
sidebarOpen,
isProjectPickerOpen,
@@ -246,6 +270,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuSubContent
className="w-[420px] bg-popover/95 backdrop-blur-xl"
data-testid="project-theme-menu"
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
</div>
<DropdownMenuSeparator />
{/* Two Column Layout */}
<div className="flex gap-2 p-2">
{/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
{/* Dark Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">

View File

@@ -1,5 +1,36 @@
import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components
* to ensure consistent viewport-aware positioning and styling.
*/
export const THEME_SUBMENU_CONSTANTS = {
/**
* Estimated total height of the theme submenu content in pixels.
* Includes all theme options, headers, padding, and "Use Global" button.
*/
ESTIMATED_SUBMENU_HEIGHT: 620,
/**
* Padding from viewport edges to prevent submenu overflow.
* Applied to both top and bottom edges when calculating available space.
*/
COLLISION_PADDING: 32,
/**
* Vertical offset from context menu top to the "Project Theme" button.
* Used for calculating submenu position relative to trigger button.
*/
THEME_BUTTON_OFFSET: 50,
/**
* Height reserved for submenu header area (includes "Use Global" button and separator).
* Subtracted from maxHeight to get scrollable content area height.
*/
SUBMENU_HEADER_HEIGHT: 80,
} as const;
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
value: opt.value,
label: opt.label,

View File

@@ -93,8 +93,8 @@ const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
maxConcurrency,
setMaxConcurrency,
maxConcurrency: legacyMaxConcurrency,
setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests,
specCreatingForProject,
setSpecCreatingForProject,
@@ -275,10 +275,24 @@ export function BoardView() {
setFeaturesWithContext,
});
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
const runningAutoTasks = autoMode.runningTasks;
// Load pipeline config when project changes
useEffect(() => {
if (!currentProject?.path) return;
const loadPipelineConfig = async () => {
try {
const api = getHttpApiClient();
const result = await api.pipeline.getConfig(currentProject.path);
if (result.success && result.config) {
setPipelineConfig(currentProject.path, result.config);
}
} catch (error) {
logger.error('Failed to load pipeline config:', error);
}
};
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
@@ -388,14 +402,6 @@ export function BoardView() {
[hookFeatures, updateFeature, persistFeatureUpdate]
);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
@@ -421,6 +427,16 @@ export function BoardView() {
}
}, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree ?? undefined);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
const maxConcurrency = autoMode.maxConcurrency;
// Get worktree-specific setter
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -429,6 +445,15 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
@@ -526,14 +551,14 @@ export function BoardView() {
try {
// Determine final branch name based on work mode:
// - 'current': Empty string to clear branch assignment (work on main/current branch)
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = '';
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -619,6 +644,7 @@ export function BoardView() {
exitSelectionMode,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
currentWorktreeBranch,
setWorktreeRefreshKey,
]
);
@@ -1139,7 +1165,21 @@ export function BoardView() {
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Also update backend if auto mode is running
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
autoMode.stop().then(() => {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
});
}
}
}}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
@@ -1406,6 +1446,7 @@ export function BoardView() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
branchName={outputFeature?.branchName}
/>
{/* Archive All Verified Dialog */}

View File

@@ -182,6 +182,13 @@ export function BoardHeader({
>
Auto Mode
</Label>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}

View File

@@ -29,6 +29,8 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
/** Branch name for the feature worktree - used when viewing changes */
branchName?: string;
}
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
@@ -41,6 +43,7 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
branchName,
}: AgentOutputModalProps) {
const isBacklogPlan = featureId.startsWith('backlog-plan:');
@@ -404,7 +407,7 @@ export function AgentOutputModal({
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={resolvedProjectPath}
featureId={featureId}
featureId={branchName || featureId}
compact={false}
useWorktrees={useWorktrees}
className="border-0 rounded-lg"

View File

@@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';

View File

@@ -0,0 +1,68 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FileText } from 'lucide-react';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface ViewWorktreeChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
projectPath: string;
}
export function ViewWorktreeChangesDialog({
open,
onOpenChange,
worktree,
projectPath,
}: ViewWorktreeChangesDialogProps) {
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
View Changes
</DialogTitle>
<DialogDescription>
Changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
<GitDiffPanel
projectPath={projectPath}
featureId={worktree.branch}
useWorktrees={true}
compact={false}
className="mt-4"
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="mobile-auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
</div>
<div className="flex items-center gap-2">
<Switch

View File

@@ -123,14 +123,15 @@ export function useBoardActions({
const workMode = featureData.workMode || 'current';
// Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree)
// - 'current': Use current worktree's branch (or undefined if on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// No worktree isolation - work directly on current branch
finalBranchName = undefined;
// Work directly on current branch - use the current worktree's branch if not on main
// This ensures features created on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -217,7 +218,7 @@ export function useBoardActions({
const api = getElectronAPI();
if (api?.features?.generateTitle) {
api.features
.generateTitle(featureData.description)
.generateTitle(featureData.description, projectPath ?? undefined)
.then((result) => {
if (result.success && result.title) {
const titleUpdates = {
@@ -250,10 +251,12 @@ export function useBoardActions({
updateFeature,
saveCategory,
currentProject,
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);
@@ -287,7 +290,9 @@ export function useBoardActions({
let finalBranchName: string | undefined;
if (workMode === 'current') {
finalBranchName = undefined;
// Work directly on current branch - use the current worktree's branch if not on main
// This ensures features updated on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -402,6 +407,7 @@ export function useBoardActions({
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);

View File

@@ -103,8 +103,25 @@ export function useBoardColumnFeatures({
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
// it's already running for the current worktree. However, we still need to check matchesWorktree
// to ensure the feature's branchName matches the current worktree's branch.
if (isRunning) {
if (!matchesWorktree) return;
// If feature is running but doesn't match worktree, it might be a timing issue where
// the feature was started for a different worktree. Still show it if it's running to
// prevent disappearing features, but log a warning.
if (!matchesWorktree) {
// This can happen if:
// 1. Feature was started for a different worktree (bug)
// 2. Timing issue where branchName hasn't been set yet
// 3. User switched worktrees while feature was starting
// Still show it in in_progress to prevent it from disappearing
console.debug(
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
);
map.in_progress.push(f);
return;
}
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];

View File

@@ -50,7 +50,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
} catch {
setPersistedCategories([]);
}
}, [currentProject]);
}, [currentProject, loadFeatures]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
@@ -87,11 +87,33 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return;
}
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_complete') {
if (event.type === 'auto_mode_feature_start') {
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
logger.info(
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
);
loadFeatures();
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {

View File

@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Get current project path for per-project Claude API profile
const currentProjectPath = useAppStore((state) => state.currentProject?.path);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
enhancementOverride.effectiveModelEntry.thinkingLevel,
currentProjectPath
);
if (result?.success && result.enhancedText) {

View File

@@ -132,11 +132,12 @@ export function DevServerLogsPanel({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
data-testid="dev-server-logs-panel"
compact
>
{/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" />

View File

@@ -25,10 +25,13 @@ import {
AlertCircle,
RefreshCw,
Copy,
Eye,
ScrollText,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
Undo2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -56,12 +59,16 @@ interface WorktreeActionsDropdownProps {
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
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;
@@ -73,6 +80,7 @@ interface WorktreeActionsDropdownProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -88,12 +96,15 @@ export function WorktreeActionsDropdown({
devServerInfo,
gitRepoStatus,
standalone = false,
isAutoModeRunning = false,
onOpenChange,
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -105,6 +116,7 @@ export function WorktreeActionsDropdown({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -214,6 +226,26 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<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>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPull(worktree)}
@@ -408,6 +440,13 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
)}
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
@@ -483,9 +522,30 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuItem
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs text-destructive focus:text-destructive',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Undo2 className="w-3.5 h-3.5 mr-2" />
Discard Changes
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive"

View File

@@ -29,6 +29,8 @@ interface WorktreeTabProps {
aheadCount: number;
behindCount: number;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -40,6 +42,8 @@ interface WorktreeTabProps {
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -51,6 +55,7 @@ interface WorktreeTabProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -75,6 +80,7 @@ export function WorktreeTab({
aheadCount,
behindCount,
gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -86,6 +92,8 @@ export function WorktreeTab({
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -97,6 +105,7 @@ export function WorktreeTab({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
@@ -332,6 +341,26 @@ export function WorktreeTab({
</TooltipProvider>
)}
{isAutoModeRunning && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
@@ -343,12 +372,15 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunning}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -360,6 +392,7 @@ export function WorktreeTab({
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
@@ -22,6 +22,10 @@ import {
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react';
export function WorktreePanel({
projectPath,
@@ -51,7 +55,6 @@ export function WorktreePanel({
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
@@ -90,10 +93,79 @@ export function WorktreePanel({
features,
});
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
return `${projectId}::${branchName ?? '__main__'}`;
},
[]
);
// Helper to check if auto-mode is running for a specific worktree
const isAutoModeRunningForWorktree = useCallback(
(worktree: WorktreeInfo): boolean => {
if (!currentProject) return false;
const branchName = worktree.isMain ? null : worktree.branch;
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
return autoModeByWorktree[key]?.isRunning ?? false;
},
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
);
// Handler to toggle auto-mode for a worktree
const handleToggleAutoMode = useCallback(
async (worktree: WorktreeInfo) => {
if (!currentProject) return;
// Import the useAutoMode to get start/stop functions
// Since useAutoMode is a hook, we'll use the API client directly
const api = getHttpApiClient();
const branchName = worktree.isMain ? null : worktree.branch;
const isRunning = isAutoModeRunningForWorktree(worktree);
try {
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode stopped for ${desc}`);
} else {
toast.error(result.error || 'Failed to stop Auto Mode');
}
} else {
const result = await api.autoMode.start(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
toast.error(result.error || 'Failed to start Auto Mode');
}
}
} catch (error) {
toast.error('Error toggling Auto Mode');
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Check if init script exists for the project using React Query
const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
// Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
@@ -161,6 +233,41 @@ export function WorktreePanel({
[projectPath]
);
const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
setViewChangesWorktree(worktree);
setViewChangesDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
}, []);
const handleConfirmDiscardChanges = useCallback(async () => {
if (!discardChangesWorktree) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
if (result.success) {
toast.success('Changes discarded', {
description: `Discarded changes in ${discardChangesWorktree.branch}`,
});
// Refresh worktrees to update the changes status
fetchWorktrees({ silent: true });
} else {
toast.error('Failed to discard changes', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to discard changes', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [discardChangesWorktree, fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -224,12 +331,15 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -241,6 +351,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -274,6 +385,36 @@ export function WorktreePanel({
</Button>
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
onClose={handleCloseLogPanel}
worktree={logPanelWorktree}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</div>
);
}
@@ -308,6 +449,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -319,6 +461,8 @@ export function WorktreePanel({
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -330,6 +474,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -368,6 +513,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
@@ -379,6 +525,8 @@ export function WorktreePanel({
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -390,6 +538,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
);
@@ -424,6 +573,27 @@ export function WorktreePanel({
</>
)}
{/* View Changes Dialog */}
<ViewWorktreeChangesDialog
open={viewChangesDialogOpen}
onOpenChange={setViewChangesDialogOpen}
worktree={viewChangesWorktree}
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}

View File

@@ -432,6 +432,7 @@ export function GraphViewPage() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
branchName={outputFeature?.branchName}
/>
{/* Backlog Plan Dialog */}

View File

@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem {
@@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Claude', icon: Bot },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
interface UseProjectSettingsViewOptions {
initialView?: ProjectSettingsViewId;

View File

@@ -0,0 +1,153 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Bot, Cloud, Server, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '@/lib/electron';
interface ProjectClaudeSectionProps {
project: Project;
}
export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
const {
claudeApiProfiles,
activeClaudeApiProfileId: globalActiveProfileId,
disabledProviders,
setProjectClaudeApiProfile,
} = useAppStore();
const { claudeAuthStatus } = useSetupStore();
// Get project-level override from project
const projectActiveProfileId = project.activeClaudeApiProfileId;
// Determine effective value for display
// undefined = use global, null = explicit direct, string = specific profile
const selectValue =
projectActiveProfileId === undefined
? 'global'
: projectActiveProfileId === null
? 'direct'
: projectActiveProfileId;
// Check if Claude is available
const isClaudeDisabled = disabledProviders.includes('claude');
const hasProfiles = claudeApiProfiles.length > 0;
const isClaudeAuthenticated = claudeAuthStatus?.authenticated;
// Get global profile name for display
const globalProfile = globalActiveProfileId
? claudeApiProfiles.find((p) => p.id === globalActiveProfileId)
: null;
const globalProfileName = globalProfile?.name || 'Direct Anthropic API';
const handleChange = (value: string) => {
// 'global' -> undefined (use global)
// 'direct' -> null (explicit direct)
// profile id -> string (specific profile)
const newValue = value === 'global' ? undefined : value === 'direct' ? null : value;
setProjectClaudeApiProfile(project.id, newValue);
};
// Don't render if Claude is disabled or not available
if (isClaudeDisabled || (!hasProfiles && !isClaudeAuthenticated)) {
return (
<div className="text-center py-12 text-muted-foreground">
<Bot className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Claude not configured</p>
<p className="text-xs mt-1">
Enable Claude and configure API profiles in global settings to use per-project profiles.
</p>
</div>
);
}
// Get the display text for current selection
const getDisplayText = () => {
if (selectValue === 'global') {
return `Using global setting: ${globalProfileName}`;
}
if (selectValue === 'direct') {
return 'Using direct Anthropic API (API key or Claude Max plan)';
}
const selectedProfile = claudeApiProfiles.find((p) => p.id === selectValue);
return `Using ${selectedProfile?.name || 'custom'} endpoint`;
};
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 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">
<Bot className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude API Profile
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Override the Claude API profile for this project only.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Active Profile for This Project</Label>
<Select value={selectValue} onValueChange={handleChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<span>Use Global Setting</span>
<span className="text-xs text-muted-foreground ml-1">({globalProfileName})</span>
</div>
</SelectItem>
<SelectItem value="direct">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-brand-500" />
<span>Direct Anthropic API</span>
</div>
</SelectItem>
{claudeApiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-muted-foreground" />
<span>{profile.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{getDisplayText()}</p>
</div>
{/* Info about what this affects */}
<div className="text-xs text-muted-foreground/70 pt-2 border-t border-border/30">
<p>This setting affects all Claude operations for this project including:</p>
<ul className="list-disc list-inside mt-1 space-y-0.5">
<li>Agent chat and feature implementation</li>
<li>Code analysis and suggestions</li>
<li>Commit message generation</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { ProjectClaudeSection } from './project-claude-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
@@ -84,6 +85,8 @@ export function ProjectSettingsView() {
return <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'claude':
return <ProjectClaudeSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -223,6 +223,7 @@ export function RunningAgentsView() {
}
featureId={selectedAgent.featureId}
featureStatus="running"
branchName={selectedAgent.branchName}
/>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
import { Key, CheckCircle2, Trash2, Info } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
@@ -101,9 +101,38 @@ export function ApiKeysSection() {
</p>
</div>
<div className="p-6 space-y-6">
{/* API Key Fields */}
{/* API Key Fields with contextual info */}
{providerConfigs.map((provider) => (
<ApiKeyField key={provider.key} config={provider} />
<div key={provider.key}>
<ApiKeyField config={provider} />
{/* Anthropic-specific profile info */}
{provider.key === 'anthropic' && (
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<span className="font-medium text-foreground/80">
Using Claude API Profiles?
</span>{' '}
Create a profile in{' '}
<span className="text-blue-500">AI Providers Claude</span> with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">
credentials
</span>{' '}
as the API key source to use this key.
</p>
<p>
For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile
with{' '}
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '}
key source and enter the provider's API key directly in the profile.
</p>
</div>
</div>
</div>
)}
</div>
))}
{/* Security Notice */}

View File

@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
import { SkillsSection } from './claude-settings-tab/skills-section';
import { SubagentsSection } from './claude-settings-tab/subagents-section';
import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section';
import { ProviderToggle } from './provider-toggle';
import { Info } from 'lucide-react';
@@ -45,6 +46,10 @@ export function ClaudeSettingsTab() {
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
{/* API Profiles for Claude-compatible endpoints */}
<ApiProfilesSection />
<ClaudeMdSettings
autoLoadClaudeMd={autoLoadClaudeMd}
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}

View File

@@ -0,0 +1,638 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import {
Cloud,
Eye,
EyeOff,
ExternalLink,
MoreVertical,
Pencil,
Plus,
Server,
Trash2,
Zap,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ClaudeApiProfile, ApiKeySource } from '@automaker/types';
import { CLAUDE_API_PROFILE_TEMPLATES } from '@automaker/types';
// Generate unique ID for profiles
function generateProfileId(): string {
return `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Mask API key for display (show first 4 + last 4 chars)
function maskApiKey(key?: string): string {
if (!key || key.length <= 8) return '••••••••';
return `${key.substring(0, 4)}••••${key.substring(key.length - 4)}`;
}
interface ProfileFormData {
name: string;
baseUrl: string;
apiKeySource: ApiKeySource;
apiKey: string;
useAuthToken: boolean;
timeoutMs: string; // String for input, convert to number
modelMappings: {
haiku: string;
sonnet: string;
opus: string;
};
disableNonessentialTraffic: boolean;
}
const emptyFormData: ProfileFormData = {
name: '',
baseUrl: '',
apiKeySource: 'inline',
apiKey: '',
useAuthToken: false,
timeoutMs: '',
modelMappings: {
haiku: '',
sonnet: '',
opus: '',
},
disableNonessentialTraffic: false,
};
export function ApiProfilesSection() {
const {
claudeApiProfiles,
activeClaudeApiProfileId,
addClaudeApiProfile,
updateClaudeApiProfile,
deleteClaudeApiProfile,
setActiveClaudeApiProfile,
} = useAppStore();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [formData, setFormData] = useState<ProfileFormData>(emptyFormData);
const [showApiKey, setShowApiKey] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [currentTemplate, setCurrentTemplate] = useState<
(typeof CLAUDE_API_PROFILE_TEMPLATES)[0] | null
>(null);
const handleOpenAddDialog = (templateName?: string) => {
const template = templateName
? CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.name === templateName)
: undefined;
if (template) {
setFormData({
name: template.name,
baseUrl: template.baseUrl,
apiKeySource: template.defaultApiKeySource ?? 'inline',
apiKey: '',
useAuthToken: template.useAuthToken,
timeoutMs: template.timeoutMs?.toString() ?? '',
modelMappings: {
haiku: template.modelMappings?.haiku ?? '',
sonnet: template.modelMappings?.sonnet ?? '',
opus: template.modelMappings?.opus ?? '',
},
disableNonessentialTraffic: template.disableNonessentialTraffic ?? false,
});
setCurrentTemplate(template);
} else {
setFormData(emptyFormData);
setCurrentTemplate(null);
}
setEditingProfileId(null);
setShowApiKey(false);
setIsDialogOpen(true);
};
const handleOpenEditDialog = (profile: ClaudeApiProfile) => {
// Find matching template by base URL
const template = CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.baseUrl === profile.baseUrl);
setFormData({
name: profile.name,
baseUrl: profile.baseUrl,
apiKeySource: profile.apiKeySource ?? 'inline',
apiKey: profile.apiKey ?? '',
useAuthToken: profile.useAuthToken ?? false,
timeoutMs: profile.timeoutMs?.toString() ?? '',
modelMappings: {
haiku: profile.modelMappings?.haiku ?? '',
sonnet: profile.modelMappings?.sonnet ?? '',
opus: profile.modelMappings?.opus ?? '',
},
disableNonessentialTraffic: profile.disableNonessentialTraffic ?? false,
});
setEditingProfileId(profile.id);
setCurrentTemplate(template ?? null);
setShowApiKey(false);
setIsDialogOpen(true);
};
const handleSave = () => {
const profileData: ClaudeApiProfile = {
id: editingProfileId ?? generateProfileId(),
name: formData.name.trim(),
baseUrl: formData.baseUrl.trim(),
apiKeySource: formData.apiKeySource,
// Only include apiKey when source is 'inline'
apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined,
useAuthToken: formData.useAuthToken,
timeoutMs: (() => {
const parsed = Number(formData.timeoutMs);
return Number.isFinite(parsed) ? parsed : undefined;
})(),
modelMappings:
formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus
? {
...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }),
...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }),
...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }),
}
: undefined,
disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined,
};
if (editingProfileId) {
updateClaudeApiProfile(editingProfileId, profileData);
} else {
addClaudeApiProfile(profileData);
}
setIsDialogOpen(false);
setFormData(emptyFormData);
setEditingProfileId(null);
};
const handleDelete = (id: string) => {
deleteClaudeApiProfile(id);
setDeleteConfirmId(null);
};
// Check for duplicate profile name (case-insensitive, excluding current profile when editing)
const isDuplicateName = claudeApiProfiles.some(
(p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProfileId
);
// API key is only required when source is 'inline'
const isFormValid =
formData.name.trim().length > 0 &&
formData.baseUrl.trim().length > 0 &&
(formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) &&
!isDuplicateName;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Server className="w-5 h-5 text-brand-500" />
</div>
<div>
<h3 className="font-semibold text-foreground">API Profiles</h3>
<p className="text-xs text-muted-foreground">Manage Claude-compatible API endpoints</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Profile
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpenAddDialog()}>
<Plus className="w-4 h-4 mr-2" />
Custom Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
{CLAUDE_API_PROFILE_TEMPLATES.map((template) => (
<DropdownMenuItem
key={template.name}
onClick={() => handleOpenAddDialog(template.name)}
>
<Zap className="w-4 h-4 mr-2" />
{template.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Active Profile Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">Active Profile</Label>
<Select
value={activeClaudeApiProfileId ?? 'none'}
onValueChange={(value) => setActiveClaudeApiProfile(value === 'none' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select active profile" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4 text-brand-500" />
Direct Anthropic API
</div>
</SelectItem>
{claudeApiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<div className="flex items-center gap-2">
<Server className="w-4 h-4 text-muted-foreground" />
{profile.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{activeClaudeApiProfileId
? 'Using custom API endpoint'
: 'Using direct Anthropic API (API key or Claude Max plan)'}
</p>
</div>
{/* Profile List */}
{claudeApiProfiles.length === 0 ? (
<div className="text-center py-8 text-muted-foreground border border-dashed border-border/50 rounded-lg">
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No API profiles configured</p>
<p className="text-xs mt-1">
Add a profile to use alternative Claude-compatible endpoints
</p>
</div>
) : (
<div className="space-y-3">
{claudeApiProfiles.map((profile) => (
<ProfileCard
key={profile.id}
profile={profile}
isActive={profile.id === activeClaudeApiProfileId}
onEdit={() => handleOpenEditDialog(profile)}
onDelete={() => setDeleteConfirmId(profile.id)}
onSetActive={() => setActiveClaudeApiProfile(profile.id)}
/>
))}
</div>
)}
</div>
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingProfileId ? 'Edit API Profile' : 'Add API Profile'}</DialogTitle>
<DialogDescription>
Configure a Claude-compatible API endpoint. API keys are stored locally.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., z.AI GLM"
className={isDuplicateName ? 'border-destructive' : ''}
/>
{isDuplicateName && (
<p className="text-xs text-destructive">A profile with this name already exists</p>
)}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label htmlFor="profile-base-url">API Base URL</Label>
<Input
id="profile-base-url"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
{/* API Key Source */}
<div className="space-y-2">
<Label>API Key Source</Label>
<Select
value={formData.apiKeySource}
onValueChange={(value: ApiKeySource) =>
setFormData({ ...formData, apiKeySource: value })
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select API key source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="credentials">
Use saved API key (from Settings API Keys)
</SelectItem>
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
<SelectItem value="inline">Enter key for this profile only</SelectItem>
</SelectContent>
</Select>
{formData.apiKeySource === 'credentials' && (
<p className="text-xs text-muted-foreground">
Will use the Anthropic key from Settings API Keys
</p>
)}
{formData.apiKeySource === 'env' && (
<p className="text-xs text-muted-foreground">
Will use ANTHROPIC_API_KEY environment variable
</p>
)}
</div>
{/* API Key (only shown for inline source) */}
{formData.apiKeySource === 'inline' && (
<div className="space-y-2">
<Label htmlFor="profile-api-key">API Key</Label>
<div className="relative">
<Input
id="profile-api-key"
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key"
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
{currentTemplate?.apiKeyUrl && (
<a
href={currentTemplate.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
>
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
{/* Use Auth Token */}
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="use-auth-token" className="font-medium">
Use Auth Token
</Label>
<p className="text-xs text-muted-foreground">
Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY
</p>
</div>
<Switch
id="use-auth-token"
checked={formData.useAuthToken}
onCheckedChange={(checked) => setFormData({ ...formData, useAuthToken: checked })}
/>
</div>
{/* Timeout */}
<div className="space-y-2">
<Label htmlFor="profile-timeout">Timeout (ms)</Label>
<Input
id="profile-timeout"
type="number"
value={formData.timeoutMs}
onChange={(e) => setFormData({ ...formData, timeoutMs: e.target.value })}
placeholder="Optional, e.g., 3000000"
/>
</div>
{/* Model Mappings */}
<div className="space-y-3">
<Label className="font-medium">Model Mappings (Optional)</Label>
<p className="text-xs text-muted-foreground -mt-1">
Map Claude model aliases to provider-specific model names
</p>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="model-haiku" className="text-xs">
Haiku
</Label>
<Input
id="model-haiku"
value={formData.modelMappings.haiku}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, haiku: e.target.value },
})
}
placeholder="e.g., GLM-4.5-Flash"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="model-sonnet" className="text-xs">
Sonnet
</Label>
<Input
id="model-sonnet"
value={formData.modelMappings.sonnet}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, sonnet: e.target.value },
})
}
placeholder="e.g., glm-4.7"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="model-opus" className="text-xs">
Opus
</Label>
<Input
id="model-opus"
value={formData.modelMappings.opus}
onChange={(e) =>
setFormData({
...formData,
modelMappings: { ...formData.modelMappings, opus: e.target.value },
})
}
placeholder="e.g., glm-4.7"
className="text-xs"
/>
</div>
</div>
</div>
{/* Disable Non-essential Traffic */}
<div className="flex items-center justify-between py-2">
<div>
<Label htmlFor="disable-traffic" className="font-medium">
Disable Non-essential Traffic
</Label>
<p className="text-xs text-muted-foreground">
Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
</p>
</div>
<Switch
id="disable-traffic"
checked={formData.disableNonessentialTraffic}
onCheckedChange={(checked) =>
setFormData({ ...formData, disableNonessentialTraffic: checked })
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isFormValid}>
{editingProfileId ? 'Save Changes' : 'Add Profile'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmId} onOpenChange={(open) => !open && setDeleteConfirmId(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Profile?</DialogTitle>
<DialogDescription>
This will permanently delete the API profile. If this profile is currently active, you
will be switched to direct Anthropic API.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface ProfileCardProps {
profile: ClaudeApiProfile;
isActive: boolean;
onEdit: () => void;
onDelete: () => void;
onSetActive: () => void;
}
function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) {
return (
<div
className={cn(
'rounded-lg border p-4 transition-colors',
isActive
? 'border-brand-500/50 bg-brand-500/5'
: 'border-border/50 bg-card/50 hover:border-border'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-foreground truncate">{profile.name}</h4>
{isActive && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-500">
Active
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-1">{profile.baseUrl}</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-muted-foreground">
<span>Key: {maskApiKey(profile.apiKey)}</span>
{profile.useAuthToken && <span>Auth Token</span>}
{profile.timeoutMs && <span>Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s</span>}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && (
<DropdownMenuItem onClick={onSetActive}>
<Zap className="w-4 h-4 mr-2" />
Set Active
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -1,13 +1,24 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath';
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
/**
* Generate a worktree key for session storage
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
*/
function getWorktreeSessionKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${branchName ?? '__main__'}`;
}
function readAutoModeSession(): Record<string, boolean> {
try {
@@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record<string, boolean>): void {
}
}
function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void {
function setAutoModeSessionForWorktree(
projectPath: string,
branchName: string | null,
running: boolean
): void {
const worktreeKey = getWorktreeSessionKey(projectPath, branchName);
const current = readAutoModeSession();
const next = { ...current, [projectPath]: running };
const next = { ...current, [worktreeKey]: running };
writeAutoModeSession(next);
}
@@ -45,33 +61,44 @@ function isPlanApprovalEvent(
}
/**
* Hook for managing auto mode (scoped per project)
* Hook for managing auto mode (scoped per worktree)
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
*/
export function useAutoMode() {
export function useAutoMode(worktree?: WorktreeInfo) {
const {
autoModeByProject,
autoModeByWorktree,
setAutoModeRunning,
addRunningTask,
removeRunningTask,
currentProject,
addAutoModeActivity,
maxConcurrency,
projects,
setPendingPlanApproval,
getWorktreeKey,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
} = useAppStore(
useShallow((state) => ({
autoModeByProject: state.autoModeByProject,
autoModeByWorktree: state.autoModeByWorktree,
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
maxConcurrency: state.maxConcurrency,
projects: state.projects,
setPendingPlanApproval: state.setPendingPlanApproval,
getWorktreeKey: state.getWorktreeKey,
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
}))
);
// Derive branchName from worktree: main worktree uses null, feature worktrees use their branch
const branchName = useMemo(() => {
if (!worktree) return null;
return worktree.isMain ? null : worktree.branch;
}, [worktree]);
// Helper to look up project ID from path
const getProjectIdFromPath = useCallback(
(path: string): string | undefined => {
@@ -81,15 +108,30 @@ export function useAutoMode() {
[projects]
);
// Get project-specific auto mode state
// Get worktree-specific auto mode state
const projectId = currentProject?.id;
const projectAutoModeState = useMemo(() => {
if (!projectId) return { isRunning: false, runningTasks: [] };
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
}, [autoModeByProject, projectId]);
const worktreeAutoModeState = useMemo(() => {
if (!projectId)
return {
isRunning: false,
runningTasks: [],
branchName: null,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
const key = getWorktreeKey(projectId, branchName);
return (
autoModeByWorktree[key] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
const isAutoModeRunning = projectAutoModeState.isRunning;
const runningAutoTasks = projectAutoModeState.runningTasks;
const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = worktreeAutoModeState.runningTasks;
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
@@ -104,15 +146,17 @@ export function useAutoMode() {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const result = await api.autoMode.status(currentProject.path);
const result = await api.autoMode.status(currentProject.path, branchName);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(currentProject.id, backendIsRunning);
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
setAutoModeRunning(currentProject.id, branchName, backendIsRunning);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
}
}
} catch (error) {
@@ -121,9 +165,9 @@ export function useAutoMode() {
};
syncWithBackend();
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
}, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects
// Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
@@ -131,8 +175,8 @@ export function useAutoMode() {
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
logger.info('Event:', event);
// Events include projectPath from backend - use it to look up project ID
// Fall back to current projectId if not provided in event
// Events include projectPath and branchName from backend
// Use them to look up project ID and determine the worktree
let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
@@ -144,6 +188,10 @@ export function useAutoMode() {
eventProjectId = projectId;
}
// Extract branchName from event, defaulting to null (main worktree)
const eventBranchName: string | null =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
// Skip event if we couldn't determine the project
if (!eventProjectId) {
logger.warn('Could not determine project for event:', event);
@@ -153,23 +201,34 @@ export function useAutoMode() {
switch (event.type) {
case 'auto_mode_started':
// Backend started auto loop - update UI state
logger.info('[AutoMode] Backend started auto loop for project');
if (eventProjectId) {
setAutoModeRunning(eventProjectId, true);
{
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`);
if (eventProjectId) {
// Extract maxConcurrency from event if available, otherwise use current or default
const eventMaxConcurrency =
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
? event.maxConcurrency
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
}
}
break;
case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state
logger.info('[AutoMode] Backend stopped auto loop for project');
if (eventProjectId) {
setAutoModeRunning(eventProjectId, false);
{
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
}
}
break;
case 'auto_mode_feature_start':
if (event.featureId) {
addRunningTask(eventProjectId, event.featureId);
addRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: 'start',
@@ -182,7 +241,7 @@ export function useAutoMode() {
// Feature completed - remove from running tasks and UI will reload features on its own
if (event.featureId) {
logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
removeRunningTask(eventProjectId, event.featureId);
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: 'complete',
@@ -202,7 +261,7 @@ export function useAutoMode() {
logger.info('Feature cancelled/aborted:', event.error);
// Remove from running tasks
if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId);
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
break;
}
@@ -229,7 +288,7 @@ export function useAutoMode() {
// Remove the task from running since it failed
if (eventProjectId) {
removeRunningTask(eventProjectId, event.featureId);
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
}
break;
@@ -404,9 +463,11 @@ export function useAutoMode() {
setPendingPlanApproval,
setAutoModeRunning,
currentProject?.path,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
]);
// Start auto mode - calls backend to start the auto loop
// Start auto mode - calls backend to start the auto loop for this worktree
const start = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
@@ -419,36 +480,35 @@ export function useAutoMode() {
throw new Error('Start auto mode API not available');
}
logger.info(
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
);
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
// Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true);
// Call backend to start the auto loop
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
// Call backend to start the auto loop (backend uses stored concurrency)
const result = await api.autoMode.start(currentProject.path, branchName);
if (!result.success) {
// Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Failed to start auto mode:', result.error);
throw new Error(result.error || 'Failed to start auto mode');
}
logger.debug(`[AutoMode] Started successfully`);
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Error starting auto mode:', error);
throw error;
}
}, [currentProject, setAutoModeRunning, maxConcurrency]);
}, [currentProject, branchName, setAutoModeRunning]);
// Stop auto mode - calls backend to stop the auto loop
// Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
@@ -461,34 +521,35 @@ export function useAutoMode() {
throw new Error('Stop auto mode API not available');
}
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`);
// Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
// Call backend to stop the auto loop
const result = await api.autoMode.stop(currentProject.path);
const result = await api.autoMode.stop(currentProject.path, branchName);
if (!result.success) {
// Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Failed to stop auto mode:', result.error);
throw new Error(result.error || 'Failed to stop auto mode');
}
// NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones.
logger.info('Stopped - running tasks will continue');
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true);
logger.error('Error stopping auto mode:', error);
throw error;
}
}, [currentProject, setAutoModeRunning]);
}, [currentProject, branchName, setAutoModeRunning]);
// Stop a specific feature
const stopFeature = useCallback(
@@ -507,7 +568,7 @@ export function useAutoMode() {
const result = await api.autoMode.stopFeature(featureId);
if (result.success) {
removeRunningTask(currentProject.id, featureId);
removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,
@@ -524,7 +585,7 @@ export function useAutoMode() {
throw error;
}
},
[currentProject, removeRunningTask, addAutoModeActivity]
[currentProject, branchName, removeRunningTask, addAutoModeActivity]
);
return {
@@ -532,6 +593,7 @@ export function useAutoMode() {
runningTasks: runningAutoTasks,
maxConcurrency,
canStartNewTask,
branchName,
start,
stop,
stopFeature,

View File

@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
const appliedProjectRef = useRef<string | null>(null);
@@ -90,6 +91,21 @@ export function useProjectSettingsLoader() {
if (settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
}
// Apply activeClaudeApiProfileId if present
if (settings.activeClaudeApiProfileId !== undefined) {
const updatedProject = useAppStore.getState().currentProject;
if (
updatedProject &&
updatedProject.path === projectPath &&
updatedProject.activeClaudeApiProfileId !== settings.activeClaudeApiProfileId
) {
setCurrentProject({
...updatedProject,
activeClaudeApiProfileId: settings.activeClaudeApiProfileId,
});
}
}
}, [
currentProject?.path,
settings,
@@ -105,5 +121,6 @@ export function useProjectSettingsLoader() {
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setCurrentProject,
]);
}

View File

@@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import {
DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
getAllCursorModelIds,
migrateCursorModelIds,
@@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
projects: state.projects as GlobalSettings['projects'],
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
@@ -206,6 +208,10 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
// Claude API Profiles
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
activeClaudeApiProfileId:
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
};
} catch (error) {
logger.error('Failed to parse localStorage settings:', error);
@@ -326,6 +332,20 @@ export function mergeSettings(
merged.currentProjectId = localSettings.currentProjectId;
}
// Claude API Profiles - preserve from localStorage if server is empty
if (
(!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) &&
localSettings.claudeApiProfiles &&
localSettings.claudeApiProfiles.length > 0
) {
merged.claudeApiProfiles = localSettings.claudeApiProfiles;
}
// Active Claude API Profile ID - preserve from localStorage if server doesn't have one
if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) {
merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
}
return merged;
}
@@ -635,13 +655,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
setItem(THEME_STORAGE_KEY, storedTheme);
}
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
const restoredAutoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency: number;
}
> = {};
if ((settings as Record<string, unknown>).autoModeByWorktree) {
const persistedSettings = (settings as Record<string, unknown>).autoModeByWorktree as Record<
string,
{ maxConcurrency?: number; branchName?: string | null }
>;
for (const [key, value] of Object.entries(persistedSettings)) {
restoredAutoModeByWorktree[key] = {
isRunning: false, // Always start with auto mode off
runningTasks: [], // No running tasks on startup
branchName: value.branchName ?? null,
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
}
}
useAppStore.setState({
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
fontFamilySans: settings.fontFamilySans ?? null,
fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true,
chatHistoryOpen: settings.chatHistoryOpen ?? false,
maxConcurrency: settings.maxConcurrency ?? 3,
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: settings.defaultSkipTests ?? true,
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
@@ -671,6 +717,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
},
mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {},
eventHooks: settings.eventHooks ?? [],
claudeApiProfiles: settings.claudeApiProfiles ?? [],
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
projects,
currentProject,
trashedProjects: settings.trashedProjects ?? [],
@@ -705,6 +754,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
function buildSettingsUpdateFromStore(): Record<string, unknown> {
const state = useAppStore.getState();
const setupState = useSetupStore.getState();
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
const persistedAutoModeByWorktree: Record<
string,
{ maxConcurrency: number; branchName: string | null }
> = {};
for (const [key, value] of Object.entries(state.autoModeByWorktree)) {
persistedAutoModeByWorktree[key] = {
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName: value.branchName,
};
}
return {
setupComplete: setupState.setupComplete,
isFirstRun: setupState.isFirstRun,
@@ -713,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency,
autoModeByWorktree: persistedAutoModeByWorktree,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
@@ -732,6 +795,9 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
keyboardShortcuts: state.keyboardShortcuts,
mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization,
eventHooks: state.eventHooks,
claudeApiProfiles: state.claudeApiProfiles,
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
projects: state.projects,
trashedProjects: state.trashedProjects,
currentProjectId: state.currentProject?.id ?? null,

View File

@@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import {
DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
getAllCursorModelIds,
migrateCursorModelIds,
@@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'sidebarOpen',
'chatHistoryOpen',
'maxConcurrency',
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
'defaultSkipTests',
'enableDependencyBlocking',
'skipVerificationInAutoMode',
@@ -72,6 +74,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultTerminalId',
'promptCustomization',
'eventHooks',
'claudeApiProfiles',
'activeClaudeApiProfileId',
'projects',
'trashedProjects',
'currentProjectId', // ID of currently open project
@@ -112,6 +116,19 @@ function getSettingsFieldValue(
if (field === 'openTerminalMode') {
return appState.terminalState.openTerminalMode;
}
if (field === 'autoModeByWorktree') {
// Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
const autoModeByWorktree = appState.autoModeByWorktree;
const persistedSettings: Record<string, { maxConcurrency: number; branchName: string | null }> =
{};
for (const [key, value] of Object.entries(autoModeByWorktree)) {
persistedSettings[key] = {
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
branchName: value.branchName,
};
}
return persistedSettings;
}
return appState[field as keyof typeof appState];
}
@@ -591,11 +608,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
setItem(THEME_STORAGE_KEY, serverSettings.theme);
}
// Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
const restoredAutoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency: number;
}
> = {};
if (serverSettings.autoModeByWorktree) {
const persistedSettings = serverSettings.autoModeByWorktree as Record<
string,
{ maxConcurrency?: number; branchName?: string | null }
>;
for (const [key, value] of Object.entries(persistedSettings)) {
restoredAutoModeByWorktree[key] = {
isRunning: false, // Always start with auto mode off
runningTasks: [], // No running tasks on startup
branchName: value.branchName ?? null,
maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
}
}
useAppStore.setState({
theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
chatHistoryOpen: serverSettings.chatHistoryOpen,
maxConcurrency: serverSettings.maxConcurrency,
autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
@@ -628,6 +671,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],
activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null,
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,
projectHistory: serverSettings.projectHistory,
@@ -637,6 +682,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '',
recentFolders: serverSettings.recentFolders ?? [],
// Event hooks
eventHooks: serverSettings.eventHooks ?? [],
// Terminal settings (nested in terminalState)
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
terminalState: {

View File

@@ -28,6 +28,7 @@ import type {
UpdateIdeaInput,
ConvertToFeatureOptions,
} from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
// Re-export issue validation types for use in components
@@ -479,20 +480,26 @@ export interface FeaturesAPI {
featureId: string
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
generateTitle: (
description: string
description: string,
projectPath?: string
) => Promise<{ success: boolean; title?: string; error?: string }>;
}
export interface AutoModeAPI {
start: (
projectPath: string,
branchName?: string | null,
maxConcurrency?: number
) => Promise<{ success: boolean; error?: string }>;
stop: (
projectPath: string
projectPath: string,
branchName?: string | null
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
status: (projectPath?: string) => Promise<{
status: (
projectPath?: string,
branchName?: string | null
) => Promise<{
success: boolean;
isRunning?: boolean;
isAutoLoopRunning?: boolean;
@@ -706,7 +713,8 @@ export interface ElectronAPI {
originalText: string,
enhancementMode: string,
model?: string,
thinkingLevel?: string
thinkingLevel?: string,
projectPath?: string
) => Promise<{
success: boolean;
enhancedText?: string;
@@ -2016,6 +2024,20 @@ function createMockWorktreeAPI(): WorktreeAPI {
console.log('[Mock] Unsubscribing from init script events');
};
},
discardChanges: async (worktreePath: string) => {
console.log('[Mock] Discarding changes:', { worktreePath });
return {
success: true,
result: {
discarded: true,
filesDiscarded: 0,
filesRemaining: 0,
branch: 'main',
message: 'Mock: Changes discarded successfully',
},
};
},
};
}
@@ -2060,7 +2082,9 @@ function createMockAutoModeAPI(): AutoModeAPI {
}
mockAutoModeRunning = true;
console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`);
console.log(
`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}`
);
const featureId = 'auto-mode-0';
mockRunningFeatures.add(featureId);
@@ -3173,7 +3197,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
return { success: true, content: content || null };
},
generateTitle: async (description: string) => {
generateTitle: async (description: string, _projectPath?: string) => {
console.log('[Mock] Generating title for:', description.substring(0, 50));
// Mock title generation - just take first few words
const words = description.split(/\s+/).slice(0, 6).join(' ');
@@ -3349,6 +3373,13 @@ export interface Project {
isFavorite?: boolean; // Pin project to top of dashboard
icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
/**
* Override the active Claude API profile for this project.
* - undefined: Use global setting (activeClaudeApiProfileId)
* - null: Explicitly use Direct Anthropic API (no profile)
* - string: Use specific profile by ID
*/
activeClaudeApiProfileId?: string | null;
}
export interface TrashedProject extends Project {

View File

@@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post('/api/features/agent-output', { projectPath, featureId }),
generateTitle: (description: string) =>
this.post('/api/features/generate-title', { description }),
generateTitle: (description: string, projectPath?: string) =>
this.post('/api/features/generate-title', { description, projectPath }),
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
bulkDelete: (projectPath: string, featureIds: string[]) =>
@@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI {
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) =>
this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }),
stop: (projectPath: string, branchName?: string | null) =>
this.post('/api/auto-mode/stop', { projectPath, branchName }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
status: (projectPath?: string, branchName?: string | null) =>
this.post('/api/auto-mode/status', { projectPath, branchName }),
runFeature: (
projectPath: string,
featureId: string,
@@ -1743,13 +1745,15 @@ export class HttpApiClient implements ElectronAPI {
originalText: string,
enhancementMode: string,
model?: string,
thinkingLevel?: string
thinkingLevel?: string,
projectPath?: string
): Promise<EnhancePromptResult> =>
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
thinkingLevel,
projectPath,
}),
};
@@ -1847,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI {
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
discardChanges: (worktreePath: string) =>
this.post('/api/worktree/discard-changes', { worktreePath }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';

View File

@@ -2,6 +2,7 @@ import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
import { setItem, getItem } from '@/lib/storage';
import {
@@ -31,6 +32,7 @@ import type {
ModelDefinition,
ServerLogLevel,
EventHook,
ClaudeApiProfile,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -38,6 +40,7 @@ import {
getAllOpencodeModelIds,
DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL,
DEFAULT_MAX_CONCURRENCY,
} from '@automaker/types';
const logger = createLogger('AppStore');
@@ -626,16 +629,18 @@ export interface AppState {
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
// Auto Mode (per-project state, keyed by project ID)
autoModeByProject: Record<
// Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
autoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
branchName: string | null; // null = main worktree
maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
}
>;
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks
maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
// Kanban Card Display Settings
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
@@ -747,6 +752,10 @@ export interface AppState {
// Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
// Claude API Profiles
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -1030,6 +1039,9 @@ export interface AppActions {
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
// Claude API Profile actions (per-project override)
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
@@ -1057,18 +1069,36 @@ export interface AppActions {
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId: string, running: boolean) => void;
addRunningTask: (projectId: string, taskId: string) => void;
removeRunningTask: (projectId: string, taskId: string) => void;
clearRunningTasks: (projectId: string) => void;
getAutoModeState: (projectId: string) => {
// Auto Mode actions (per-worktree)
setAutoModeRunning: (
projectId: string,
branchName: string | null,
running: boolean,
maxConcurrency?: number
) => void;
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
clearRunningTasks: (projectId: string, branchName: string | null) => void;
getAutoModeState: (
projectId: string,
branchName: string | null
) => {
isRunning: boolean;
runningTasks: string[];
branchName: string | null;
maxConcurrency?: number;
};
/** Helper to generate worktree key from projectId and branchName */
getWorktreeKey: (projectId: string, branchName: string | null) => string;
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
clearAutoModeActivity: () => void;
setMaxConcurrency: (max: number) => void;
setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
setMaxConcurrencyForWorktree: (
projectId: string,
branchName: string | null,
maxConcurrency: number
) => void;
// Kanban Card Settings actions
setBoardViewMode: (mode: BoardViewMode) => void;
@@ -1180,6 +1210,13 @@ export interface AppActions {
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => void;
// Claude API Profile actions
addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise<void>;
updateClaudeApiProfile: (id: string, updates: Partial<ClaudeApiProfile>) => Promise<void>;
deleteClaudeApiProfile: (id: string) => Promise<void>;
setActiveClaudeApiProfile: (id: string | null) => Promise<void>;
setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise<void>;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
@@ -1387,9 +1424,9 @@ const initialState: AppState = {
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
autoModeByProject: {},
autoModeByWorktree: {},
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents
boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
@@ -1438,6 +1475,8 @@ const initialState: AppState = {
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
eventHooks: [], // No event hooks configured by default
claudeApiProfiles: [], // No Claude API profiles configured by default
activeClaudeApiProfileId: null, // Use direct Anthropic API by default
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
@@ -1936,6 +1975,47 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
// Claude API Profile actions (per-project override)
setProjectClaudeApiProfile: (projectId, profileId) => {
// Find the project to get its path for server sync
const project = get().projects.find((p) => p.id === projectId);
if (!project) {
console.error('Cannot set Claude API profile: project not found');
return;
}
// Update the project's activeClaudeApiProfileId property
// undefined means "use global", null means "explicit direct API", string means specific profile
const projects = get().projects.map((p) =>
p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p
);
set({ projects });
// Also update currentProject if it's the same project
const currentProject = get().currentProject;
if (currentProject?.id === projectId) {
set({
currentProject: {
...currentProject,
activeClaudeApiProfileId: profileId,
},
});
}
// Persist to server
// Note: undefined means "use global" but JSON doesn't serialize undefined,
// so we use a special marker string "__USE_GLOBAL__" to signal deletion
const httpClient = getHttpApiClient();
const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId;
httpClient.settings
.updateProject(project.path, {
activeClaudeApiProfileId: serverValue,
})
.catch((error) => {
console.error('Failed to persist activeClaudeApiProfileId:', error);
});
},
// Feature actions
setFeatures: (features) => set({ features }),
@@ -2073,74 +2153,125 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId, running) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || {
// Auto Mode actions (per-worktree)
getWorktreeKey: (projectId, branchName) => {
return `${projectId}::${branchName ?? '__main__'}`;
},
setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
set({
autoModeByProject: {
autoModeByWorktree: {
...current,
[projectId]: { ...projectState, isRunning: running },
[worktreeKey]: {
...worktreeState,
isRunning: running,
branchName,
maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
},
},
});
},
addRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || {
addRunningTask: (projectId, branchName, taskId) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
};
if (!projectState.runningTasks.includes(taskId)) {
if (!worktreeState.runningTasks.includes(taskId)) {
set({
autoModeByProject: {
autoModeByWorktree: {
...current,
[projectId]: {
...projectState,
runningTasks: [...projectState.runningTasks, taskId],
[worktreeKey]: {
...worktreeState,
runningTasks: [...worktreeState.runningTasks, taskId],
branchName,
},
},
});
}
},
removeRunningTask: (projectId, taskId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || {
removeRunningTask: (projectId, branchName, taskId) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
};
set({
autoModeByProject: {
autoModeByWorktree: {
...current,
[projectId]: {
...projectState,
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
[worktreeKey]: {
...worktreeState,
runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId),
branchName,
},
},
});
},
clearRunningTasks: (projectId) => {
const current = get().autoModeByProject;
const projectState = current[projectId] || {
clearRunningTasks: (projectId, branchName) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
};
set({
autoModeByProject: {
autoModeByWorktree: {
...current,
[projectId]: { ...projectState, runningTasks: [] },
[worktreeKey]: { ...worktreeState, runningTasks: [], branchName },
},
});
},
getAutoModeState: (projectId) => {
const projectState = get().autoModeByProject[projectId];
return projectState || { isRunning: false, runningTasks: [] };
getAutoModeState: (projectId, branchName) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const worktreeState = get().autoModeByWorktree[worktreeKey];
return (
worktreeState || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
},
getMaxConcurrencyForWorktree: (projectId, branchName) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const worktreeState = get().autoModeByWorktree[worktreeKey];
return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
},
setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
set({
autoModeByWorktree: {
...current,
[worktreeKey]: { ...worktreeState, maxConcurrency, branchName },
},
});
},
addAutoModeActivity: (activity) => {
@@ -2459,6 +2590,82 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }),
// Claude API Profile actions
addClaudeApiProfile: async (profile) => {
set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] });
// Sync immediately to persist profile
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
updateClaudeApiProfile: async (id, updates) => {
set({
claudeApiProfiles: get().claudeApiProfiles.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
});
// Sync immediately to persist changes
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
deleteClaudeApiProfile: async (id) => {
const currentActiveId = get().activeClaudeApiProfileId;
const projects = get().projects;
// Find projects that have per-project override referencing the deleted profile
const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id);
// Update state: remove profile and clear references
set({
claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id),
// Clear global active if the deleted profile was active
activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId,
// Clear per-project overrides that reference the deleted profile
projects: projects.map((p) =>
p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p
),
});
// Also update currentProject if it was using the deleted profile
const currentProject = get().currentProject;
if (currentProject?.activeClaudeApiProfileId === id) {
set({
currentProject: { ...currentProject, activeClaudeApiProfileId: undefined },
});
}
// Persist per-project changes to server (use __USE_GLOBAL__ marker)
const httpClient = getHttpApiClient();
await Promise.all(
affectedProjects.map((project) =>
httpClient.settings
.updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' })
.catch((error) => {
console.error(`Failed to clear profile override for project ${project.name}:`, error);
})
)
);
// Sync global settings to persist deletion
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setActiveClaudeApiProfile: async (id) => {
set({ activeClaudeApiProfileId: id });
// Sync immediately to persist active profile change
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setClaudeApiProfiles: async (profiles) => {
set({ claudeApiProfiles: profiles });
// Sync immediately to persist profiles
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
// MCP Server actions
addMCPServer: (server) => {
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

View File

@@ -163,11 +163,30 @@ export interface SessionsAPI {
}
export type AutoModeEvent =
| {
type: 'auto_mode_started';
message: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_stopped';
message: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_idle';
message: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_feature_start';
featureId: string;
projectId?: string;
projectPath?: string;
branchName?: string | null;
feature: unknown;
}
| {
@@ -175,6 +194,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
branchName?: string | null;
content: string;
}
| {
@@ -182,6 +202,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
branchName?: string | null;
tool: string;
input: unknown;
}
@@ -190,6 +211,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
branchName?: string | null;
passes: boolean;
message: string;
}
@@ -218,6 +240,7 @@ export type AutoModeEvent =
featureId?: string;
projectId?: string;
projectPath?: string;
branchName?: string | null;
}
| {
type: 'auto_mode_phase';
@@ -389,18 +412,48 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
start: (
projectPath: string,
branchName?: string | null,
maxConcurrency?: number
) => Promise<{
success: boolean;
message?: string;
alreadyRunning?: boolean;
branchName?: string | null;
error?: string;
}>;
stop: (
projectPath: string,
branchName?: string | null
) => Promise<{
success: boolean;
message?: string;
wasRunning?: boolean;
runningFeaturesCount?: number;
branchName?: string | null;
error?: string;
}>;
stopFeature: (featureId: string) => Promise<{
success: boolean;
error?: string;
}>;
status: (projectPath?: string) => Promise<{
status: (
projectPath?: string,
branchName?: string | null
) => Promise<{
success: boolean;
isRunning?: boolean;
isAutoLoopRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
maxConcurrency?: number;
branchName?: string | null;
error?: string;
}>;
@@ -1165,6 +1218,19 @@ export interface WorktreeAPI {
payload: unknown;
}) => void
) => () => void;
// Discard changes for a worktree
discardChanges: (worktreePath: string) => Promise<{
success: boolean;
result?: {
discarded: boolean;
filesDiscarded: number;
filesRemaining: number;
branch: string;
message: string;
};
error?: string;
}>;
}
export interface GitAPI {