mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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:
@@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session';
|
|||||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
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
|
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
|
// Session store - persisted to file for survival across server restarts
|
||||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||||
|
|
||||||
@@ -134,8 +141,8 @@ const API_KEY = ensureApiKey();
|
|||||||
const BOX_CONTENT_WIDTH = 67;
|
const BOX_CONTENT_WIDTH = 67;
|
||||||
|
|
||||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
|
||||||
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
|
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
|
||||||
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
||||||
|
|
||||||
// Build box lines with exact padding
|
// Build box lines with exact padding
|
||||||
@@ -375,6 +382,12 @@ function checkAuthentication(
|
|||||||
* 5. Session cookie (for web mode)
|
* 5. Session cookie (for web mode)
|
||||||
*/
|
*/
|
||||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
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(
|
const result = checkAuthentication(
|
||||||
req.headers as Record<string, string | string[] | undefined>,
|
req.headers as Record<string, string | string[] | undefined>,
|
||||||
req.query as Record<string, string | undefined>,
|
req.query as Record<string, string | undefined>,
|
||||||
@@ -420,9 +433,10 @@ export function isAuthEnabled(): boolean {
|
|||||||
* Get authentication status for health endpoint
|
* Get authentication status for health endpoint
|
||||||
*/
|
*/
|
||||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||||
|
const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: !disabled,
|
||||||
method: 'api_key_or_session',
|
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)
|
* Check if a request is authenticated (for status endpoint)
|
||||||
*/
|
*/
|
||||||
export function isRequestAuthenticated(req: Request): boolean {
|
export function isRequestAuthenticated(req: Request): boolean {
|
||||||
|
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||||
const result = checkAuthentication(
|
const result = checkAuthentication(
|
||||||
req.headers as Record<string, string | string[] | undefined>,
|
req.headers as Record<string, string | string[] | undefined>,
|
||||||
req.query as Record<string, string | undefined>,
|
req.query as Record<string, string | undefined>,
|
||||||
@@ -447,5 +462,6 @@ export function checkRawAuthentication(
|
|||||||
query: Record<string, string | undefined>,
|
query: Record<string, string | undefined>,
|
||||||
cookies: Record<string, string | undefined>
|
cookies: Record<string, string | undefined>
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||||
return checkAuthentication(headers, query, cookies).authenticated;
|
return checkAuthentication(headers, query, cookies).authenticated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
import type { SettingsService } from '../services/settings-service.js';
|
import type { SettingsService } from '../services/settings-service.js';
|
||||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||||
import { createLogger } 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 {
|
import {
|
||||||
mergeAutoModePrompts,
|
mergeAutoModePrompts,
|
||||||
mergeAgentPrompts,
|
mergeAgentPrompts,
|
||||||
@@ -345,3 +350,80 @@ export async function getCustomSubagents(
|
|||||||
|
|
||||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
|
|||||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeProvider');
|
const logger = createLogger('ClaudeProvider');
|
||||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
import {
|
||||||
|
getThinkingTokenBudget,
|
||||||
|
validateBareModelId,
|
||||||
|
type ClaudeApiProfile,
|
||||||
|
type Credentials,
|
||||||
|
} from '@automaker/types';
|
||||||
import type {
|
import type {
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
@@ -21,9 +26,19 @@ import type {
|
|||||||
// Explicit allowlist of environment variables to pass to the SDK.
|
// Explicit allowlist of environment variables to pass to the SDK.
|
||||||
// Only these vars are passed - nothing else from process.env leaks through.
|
// Only these vars are passed - nothing else from process.env leaks through.
|
||||||
const ALLOWED_ENV_VARS = [
|
const ALLOWED_ENV_VARS = [
|
||||||
|
// Authentication
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
'ANTHROPIC_BASE_URL',
|
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
'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',
|
'PATH',
|
||||||
'HOME',
|
'HOME',
|
||||||
'SHELL',
|
'SHELL',
|
||||||
@@ -33,16 +48,114 @@ const ALLOWED_ENV_VARS = [
|
|||||||
'LC_ALL',
|
'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> = {};
|
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]) {
|
if (process.env[key]) {
|
||||||
env[key] = process.env[key];
|
env[key] = process.env[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +183,8 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
conversationHistory,
|
conversationHistory,
|
||||||
sdkSessionId,
|
sdkSessionId,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
claudeApiProfile,
|
||||||
|
credentials,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Convert thinking level to token budget
|
// Convert thinking level to token budget
|
||||||
@@ -82,7 +197,9 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
maxTurns,
|
maxTurns,
|
||||||
cwd,
|
cwd,
|
||||||
// Pass only explicitly allowed environment variables to SDK
|
// 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)
|
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||||
...(allowedTools && { allowedTools }),
|
...(allowedTools && { allowedTools }),
|
||||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
|
||||||
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
@@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider {
|
|||||||
args.push('--format', 'json');
|
args.push('--format', 'json');
|
||||||
|
|
||||||
// Handle model selection
|
// 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) {
|
if (options.model) {
|
||||||
const model = stripProviderPrefix(options.model);
|
// Strip opencode- prefix if present, then ensure slash format
|
||||||
args.push('--model', model);
|
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
|
// Note: OpenCode reads from stdin automatically when input is piped
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
ContentBlock,
|
ContentBlock,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
|
ClaudeApiProfile,
|
||||||
|
Credentials,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
|
|
||||||
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Setting sources for CLAUDE.md loading */
|
/** Setting sources for CLAUDE.md loading */
|
||||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
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,
|
reasoningEffort: options.reasoningEffort,
|
||||||
readOnly: options.readOnly,
|
readOnly: options.readOnly,
|
||||||
settingSources: options.settingSources,
|
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)) {
|
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||||
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
|
|||||||
reasoningEffort: options.reasoningEffort,
|
reasoningEffort: options.reasoningEffort,
|
||||||
readOnly: options.readOnly,
|
readOnly: options.readOnly,
|
||||||
settingSources: options.settingSources,
|
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)) {
|
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
|
|||||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.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';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
|
|
||||||
const logger = createLogger('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
@@ -123,6 +127,13 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
|||||||
|
|
||||||
logger.info('Using model:', model);
|
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
|
// Use streamingQuery with event callbacks
|
||||||
const result = await streamingQuery({
|
const result = await streamingQuery({
|
||||||
prompt,
|
prompt,
|
||||||
@@ -134,6 +145,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
readOnly: true, // Feature generation only reads code, doesn't write
|
readOnly: true, // Feature generation only reads code, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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) => {
|
onText: (text) => {
|
||||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||||
events.emit('spec-regeneration:event', {
|
events.emit('spec-regeneration:event', {
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
|
|||||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.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('SpecRegeneration');
|
const logger = createLogger('SpecRegeneration');
|
||||||
|
|
||||||
@@ -100,6 +104,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
|||||||
|
|
||||||
logger.info('Using model:', model);
|
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 responseText = '';
|
||||||
let structuredOutput: SpecOutput | null = null;
|
let structuredOutput: SpecOutput | null = null;
|
||||||
|
|
||||||
@@ -132,6 +143,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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
|
outputFormat: useStructuredOutput
|
||||||
? {
|
? {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
|
|||||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
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 { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import {
|
import {
|
||||||
extractImplementedFeatures,
|
extractImplementedFeatures,
|
||||||
@@ -157,6 +160,13 @@ export async function syncSpec(
|
|||||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
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
|
// Use AI to analyze tech stack
|
||||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology 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,
|
thinkingLevel,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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) => {
|
onText: (text) => {
|
||||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
|
|||||||
export function createStartHandler(autoModeService: AutoModeService) {
|
export function createStartHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, maxConcurrency } = req.body as {
|
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
branchName?: string | null;
|
||||||
maxConcurrency?: number;
|
maxConcurrency?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize branchName: undefined becomes null
|
||||||
|
const normalizedBranchName = branchName ?? null;
|
||||||
|
const worktreeDesc = normalizedBranchName
|
||||||
|
? `worktree ${normalizedBranchName}`
|
||||||
|
: 'main worktree';
|
||||||
|
|
||||||
// Check if already running
|
// Check if already running
|
||||||
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Auto mode is already running for this project',
|
message: `Auto mode is already running for ${worktreeDesc}`,
|
||||||
alreadyRunning: true,
|
alreadyRunning: true,
|
||||||
|
branchName: normalizedBranchName,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the auto loop for this project
|
// Start the auto loop for this project/worktree
|
||||||
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
|
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
|
||||||
|
projectPath,
|
||||||
|
normalizedBranchName,
|
||||||
|
maxConcurrency
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
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({
|
res.json({
|
||||||
success: true,
|
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) {
|
} catch (error) {
|
||||||
logError(error, 'Start auto mode failed');
|
logError(error, 'Start auto mode failed');
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
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) {
|
if (projectPath) {
|
||||||
const projectStatus = autoModeService.getStatusForProject(projectPath);
|
// Normalize branchName: undefined becomes null
|
||||||
|
const normalizedBranchName = branchName ?? null;
|
||||||
|
const projectStatus = autoModeService.getStatusForProject(
|
||||||
|
projectPath,
|
||||||
|
normalizedBranchName
|
||||||
|
);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
isRunning: projectStatus.runningCount > 0,
|
isRunning: projectStatus.runningCount > 0,
|
||||||
@@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
|||||||
runningCount: projectStatus.runningCount,
|
runningCount: projectStatus.runningCount,
|
||||||
maxConcurrency: projectStatus.maxConcurrency,
|
maxConcurrency: projectStatus.maxConcurrency,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName: normalizedBranchName,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
|||||||
// Fall back to global status for backward compatibility
|
// Fall back to global status for backward compatibility
|
||||||
const status = autoModeService.getStatus();
|
const status = autoModeService.getStatus();
|
||||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||||
|
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
...status,
|
...status,
|
||||||
activeAutoLoopProjects: activeProjects,
|
activeAutoLoopProjects: activeProjects,
|
||||||
|
activeAutoLoopWorktrees: activeWorktrees,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Get status failed');
|
logError(error, 'Get status failed');
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
|
|||||||
export function createStopHandler(autoModeService: AutoModeService) {
|
export function createStopHandler(autoModeService: AutoModeService) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath } = req.body as {
|
const { projectPath, branchName } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
branchName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
@@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize branchName: undefined becomes null
|
||||||
|
const normalizedBranchName = branchName ?? null;
|
||||||
|
const worktreeDesc = normalizedBranchName
|
||||||
|
? `worktree ${normalizedBranchName}`
|
||||||
|
: 'main worktree';
|
||||||
|
|
||||||
// Check if running
|
// Check if running
|
||||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Auto mode is not running for this project',
|
message: `Auto mode is not running for ${worktreeDesc}`,
|
||||||
wasRunning: false,
|
wasRunning: false,
|
||||||
|
branchName: normalizedBranchName,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the auto loop for this project
|
// Stop the auto loop for this project/worktree
|
||||||
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
|
const runningCount = await autoModeService.stopAutoLoopForProject(
|
||||||
|
projectPath,
|
||||||
|
normalizedBranchName
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Auto mode stopped',
|
message: 'Auto mode stopped',
|
||||||
runningFeaturesCount: runningCount,
|
runningFeaturesCount: runningCount,
|
||||||
|
branchName: normalizedBranchName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Stop auto mode failed');
|
logError(error, 'Stop auto mode failed');
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import {
|
|||||||
saveBacklogPlan,
|
saveBacklogPlan,
|
||||||
} from './common.js';
|
} from './common.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.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();
|
const featureLoader = new FeatureLoader();
|
||||||
|
|
||||||
@@ -161,6 +165,13 @@ ${userPrompt}`;
|
|||||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
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
|
// Execute the query
|
||||||
const stream = provider.executeQuery({
|
const stream = provider.executeQuery({
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
@@ -173,6 +184,8 @@ ${userPrompt}`;
|
|||||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||||
thinkingLevel, // Pass thinking level for extended thinking
|
thinkingLevel, // Pass thinking level for extended thinking
|
||||||
|
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||||
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
});
|
});
|
||||||
|
|
||||||
let responseText = '';
|
let responseText = '';
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
|||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
|
getActiveClaudeApiProfile,
|
||||||
} from '../../../lib/settings-helpers.js';
|
} from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('DescribeFile');
|
const logger = createLogger('DescribeFile');
|
||||||
@@ -165,6 +166,13 @@ ${contentToAnalyze}`;
|
|||||||
|
|
||||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
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
|
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||||
const result = await simpleQuery({
|
const result = await simpleQuery({
|
||||||
prompt,
|
prompt,
|
||||||
@@ -175,6 +183,8 @@ ${contentToAnalyze}`;
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
readOnly: true, // File description only reads, doesn't write
|
readOnly: true, // File description only reads, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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;
|
const description = result.text;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
|||||||
import {
|
import {
|
||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getPromptCustomization,
|
getPromptCustomization,
|
||||||
|
getActiveClaudeApiProfile,
|
||||||
} from '../../../lib/settings-helpers.js';
|
} from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('DescribeImage');
|
const logger = createLogger('DescribeImage');
|
||||||
@@ -284,6 +285,13 @@ export function createDescribeImageHandler(
|
|||||||
// Get customized prompts from settings
|
// Get customized prompts from settings
|
||||||
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
|
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
|
// Build the instruction text from centralized prompts
|
||||||
const instructionText = prompts.contextDescription.describeImagePrompt;
|
const instructionText = prompts.contextDescription.describeImagePrompt;
|
||||||
|
|
||||||
@@ -325,6 +333,8 @@ export function createDescribeImageHandler(
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
readOnly: true, // Image description only reads, doesn't write
|
readOnly: true, // Image description only reads, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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`);
|
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
|
|||||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
import type { SettingsService } from '../../../services/settings-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 {
|
import {
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
isValidEnhancementMode,
|
isValidEnhancementMode,
|
||||||
@@ -33,6 +36,8 @@ interface EnhanceRequestBody {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** Optional thinking level for Claude models */
|
/** Optional thinking level for Claude models */
|
||||||
thinkingLevel?: ThinkingLevel;
|
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> {
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { originalText, enhancementMode, model, thinkingLevel } =
|
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
|
||||||
req.body as EnhanceRequestBody;
|
req.body as EnhanceRequestBody;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -126,6 +131,14 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
logger.debug(`Using model: ${resolvedModel}`);
|
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
|
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||||
// The system prompt is combined with user prompt since some providers
|
// The system prompt is combined with user prompt since some providers
|
||||||
// don't have a separate system prompt concept
|
// don't have a separate system prompt concept
|
||||||
@@ -137,6 +150,8 @@ export function createEnhanceHandler(
|
|||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
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;
|
const enhancedText = result.text;
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ import { createLogger } from '@automaker/utils';
|
|||||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
import type { SettingsService } from '../../../services/settings-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');
|
const logger = createLogger('GenerateTitle');
|
||||||
|
|
||||||
interface GenerateTitleRequestBody {
|
interface GenerateTitleRequestBody {
|
||||||
description: string;
|
description: string;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenerateTitleSuccessResponse {
|
interface GenerateTitleSuccessResponse {
|
||||||
@@ -33,7 +37,7 @@ export function createGenerateTitleHandler(
|
|||||||
): (req: Request, res: Response) => Promise<void> {
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { description } = req.body as GenerateTitleRequestBody;
|
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
||||||
|
|
||||||
if (!description || typeof description !== 'string') {
|
if (!description || typeof description !== 'string') {
|
||||||
const response: GenerateTitleErrorResponse = {
|
const response: GenerateTitleErrorResponse = {
|
||||||
@@ -60,6 +64,14 @@ export function createGenerateTitleHandler(
|
|||||||
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
|
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
|
||||||
const systemPrompt = prompts.titleGeneration.systemPrompt;
|
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}`;
|
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||||
|
|
||||||
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
||||||
@@ -69,6 +81,8 @@ export function createGenerateTitleHandler(
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||||
|
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = result.text;
|
const title = result.text;
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
ValidationComment,
|
ValidationComment,
|
||||||
ValidationLinkedPR,
|
ValidationLinkedPR,
|
||||||
} from './validation-schema.js';
|
} from './validation-schema.js';
|
||||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
import {
|
||||||
|
getPromptCustomization,
|
||||||
|
getAutoLoadClaudeMdSetting,
|
||||||
|
getActiveClaudeApiProfile,
|
||||||
|
} from '../../../lib/settings-helpers.js';
|
||||||
import {
|
import {
|
||||||
trySetValidationRunning,
|
trySetValidationRunning,
|
||||||
clearValidationStatus,
|
clearValidationStatus,
|
||||||
@@ -43,7 +47,6 @@ import {
|
|||||||
logger,
|
logger,
|
||||||
} from './validation-common.js';
|
} from './validation-common.js';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request body for issue validation
|
* Request body for issue validation
|
||||||
@@ -166,6 +169,13 @@ ${basePrompt}`;
|
|||||||
|
|
||||||
logger.info(`Using model: ${model}`);
|
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
|
// Use streamingQuery with event callbacks
|
||||||
const result = await streamingQuery({
|
const result = await streamingQuery({
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
@@ -177,6 +187,8 @@ ${basePrompt}`;
|
|||||||
reasoningEffort: effectiveReasoningEffort,
|
reasoningEffort: effectiveReasoningEffort,
|
||||||
readOnly: true, // Issue validation only reads code, doesn't write
|
readOnly: true, // Issue validation only reads code, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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
|
outputFormat: useStructuredOutput
|
||||||
? {
|
? {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
|
|||||||
import { getAppSpecPath } from '@automaker/platform';
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import * as secureFs from '../../lib/secure-fs.js';
|
import * as secureFs from '../../lib/secure-fs.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.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');
|
const logger = createLogger('Suggestions');
|
||||||
|
|
||||||
@@ -192,6 +196,13 @@ ${prompts.suggestions.baseTemplate}`;
|
|||||||
|
|
||||||
logger.info('[Suggestions] Using model:', model);
|
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 = '';
|
let responseText = '';
|
||||||
|
|
||||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
// 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,
|
thinkingLevel,
|
||||||
readOnly: true, // Suggestions only reads code, doesn't write
|
readOnly: true, // Suggestions only reads code, doesn't write
|
||||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
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
|
outputFormat: useStructuredOutput
|
||||||
? {
|
? {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
createDeleteInitScriptHandler,
|
createDeleteInitScriptHandler,
|
||||||
createRunInitScriptHandler,
|
createRunInitScriptHandler,
|
||||||
} from './routes/init-script.js';
|
} from './routes/init-script.js';
|
||||||
|
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -148,5 +149,13 @@ export function createWorktreeRoutes(
|
|||||||
createRunInitScriptHandler(events)
|
createRunInitScriptHandler(events)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Discard changes route
|
||||||
|
router.post(
|
||||||
|
'/discard-changes',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createDiscardChangesHandler()
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export function createDiffsHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Git worktrees are stored in project directory
|
// 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 {
|
try {
|
||||||
// Check if worktree exists
|
// Check if worktree exists
|
||||||
|
|||||||
112
apps/server/src/routes/worktree/routes/discard-changes.ts
Normal file
112
apps/server/src/routes/worktree/routes/discard-changes.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,7 +37,10 @@ export function createFileDiffHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Git worktrees are stored in project directory
|
// 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 {
|
try {
|
||||||
await secureFs.access(worktreePath);
|
await secureFs.access(worktreePath);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { exec } from 'child_process';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||||
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
|
|||||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('GenerateCommitMessage');
|
const logger = createLogger('GenerateCommitMessage');
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
|
|||||||
error: string;
|
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(
|
export function createGenerateCommitMessageHandler(
|
||||||
settingsService?: SettingsService
|
settingsService?: SettingsService
|
||||||
): (req: Request, res: Response) => Promise<void> {
|
): (req: Request, res: Response) => Promise<void> {
|
||||||
@@ -195,57 +168,54 @@ export function createGenerateCommitMessageHandler(
|
|||||||
// Get the effective system prompt (custom or default)
|
// Get the effective system prompt (custom or default)
|
||||||
const systemPrompt = await getSystemPrompt(settingsService);
|
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
|
// Get provider for the model type
|
||||||
if (isCursorModel(model)) {
|
const provider = ProviderFactory.getProviderForModel(model);
|
||||||
// Use Cursor provider for Cursor models
|
const bareModel = stripProviderPrefix(model);
|
||||||
logger.info(`Using Cursor provider for model: ${model}`);
|
|
||||||
|
|
||||||
const provider = ProviderFactory.getProviderForModel(model);
|
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
|
||||||
const bareModel = stripProviderPrefix(model);
|
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 = '';
|
let responseText = '';
|
||||||
const cursorStream = provider.executeQuery({
|
const stream = provider.executeQuery({
|
||||||
prompt: cursorPrompt,
|
prompt: effectivePrompt,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxTurns: 1,
|
systemPrompt: effectiveSystemPrompt,
|
||||||
allowedTools: [],
|
maxTurns: 1,
|
||||||
readOnly: true,
|
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
|
// Wrap with timeout to prevent indefinite hangs
|
||||||
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
|
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
|
||||||
if (msg.type === 'assistant' && msg.message?.content) {
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
for (const block of msg.message.content) {
|
for (const block of msg.message.content) {
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
responseText += 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) {
|
if (!message || message.trim().length === 0) {
|
||||||
logger.warn('Received empty response from model');
|
logger.warn('Received empty response from model');
|
||||||
const response: GenerateCommitMessageErrorResponse = {
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function createInfoHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if worktree exists (git worktrees are stored in project directory)
|
// 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 {
|
try {
|
||||||
await secureFs.access(worktreePath);
|
await secureFs.access(worktreePath);
|
||||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function createStatusHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Git worktrees are stored in project directory
|
// 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 {
|
try {
|
||||||
await secureFs.access(worktreePath);
|
await secureFs.access(worktreePath);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
getSkillsConfiguration,
|
getSkillsConfiguration,
|
||||||
getSubagentsConfiguration,
|
getSubagentsConfiguration,
|
||||||
getCustomSubagents,
|
getCustomSubagents,
|
||||||
|
getActiveClaudeApiProfile,
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -274,6 +275,13 @@ export class AgentService {
|
|||||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||||
: undefined;
|
: 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
|
// 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
|
// Use the user's message as task context for smart memory selection
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
@@ -378,6 +386,8 @@ export class AgentService {
|
|||||||
agents: customSubagents, // Pass custom subagents for task delegation
|
agents: customSubagents, // Pass custom subagents for task delegation
|
||||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex 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
|
// Build prompt content with images
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -468,10 +468,41 @@ export class ClaudeUsageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip ANSI escape codes from text
|
* Strip ANSI escape codes from text
|
||||||
|
* Handles CSI, OSC, and other common ANSI sequences
|
||||||
*/
|
*/
|
||||||
private stripAnsiCodes(text: string): string {
|
private stripAnsiCodes(text: string): string {
|
||||||
|
// First strip ANSI sequences (colors, etc) and handle CR
|
||||||
// eslint-disable-next-line no-control-regex
|
// 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,
|
sectionLabel: string,
|
||||||
type: string
|
type: string
|
||||||
): { percentage: number; resetTime: string; resetText: string } {
|
): { percentage: number; resetTime: string; resetText: string } {
|
||||||
let percentage = 0;
|
let percentage: number | null = null;
|
||||||
let resetTime = this.getDefaultResetTime(type);
|
let resetTime = this.getDefaultResetTime(type);
|
||||||
let resetText = '';
|
let resetText = '';
|
||||||
|
|
||||||
@@ -564,7 +595,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sectionIndex === -1) {
|
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)
|
// 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) {
|
for (const line of searchWindow) {
|
||||||
// Extract percentage - only take the first match (avoid picking up next section's data)
|
// 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);
|
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
|
||||||
if (percentMatch) {
|
if (percentMatch) {
|
||||||
const value = parseInt(percentMatch[1], 10);
|
const value = parseInt(percentMatch[1], 10);
|
||||||
@@ -584,18 +616,31 @@ export class ClaudeUsageService {
|
|||||||
|
|
||||||
// Extract reset time - only take the first match
|
// Extract reset time - only take the first match
|
||||||
if (!resetText && line.toLowerCase().includes('reset')) {
|
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
|
// Parse the reset time if we found one
|
||||||
if (resetText) {
|
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);
|
resetTime = this.parseResetTime(resetText, type);
|
||||||
// Strip timezone like "(Asia/Dubai)" from the display text
|
// Strip timezone like "(Asia/Dubai)" from the display text
|
||||||
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
|
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"
|
// 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) {
|
if (simpleTimeMatch) {
|
||||||
let hours = parseInt(simpleTimeMatch[1], 10);
|
let hours = parseInt(simpleTimeMatch[1], 10);
|
||||||
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
|
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"
|
// 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(
|
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) {
|
if (dateMatch) {
|
||||||
const monthName = dateMatch[1];
|
const monthName = dateMatch[1];
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ interface HookContext {
|
|||||||
interface AutoModeEventPayload {
|
interface AutoModeEventPayload {
|
||||||
type?: string;
|
type?: string;
|
||||||
featureId?: string;
|
featureId?: string;
|
||||||
|
featureName?: string;
|
||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -152,6 +153,7 @@ export class EventHookService {
|
|||||||
// Build context for variable substitution
|
// Build context for variable substitution
|
||||||
const context: HookContext = {
|
const context: HookContext = {
|
||||||
featureId: payload.featureId,
|
featureId: payload.featureId,
|
||||||
|
featureName: payload.featureName,
|
||||||
projectPath: payload.projectPath,
|
projectPath: payload.projectPath,
|
||||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||||
error: payload.error || payload.message,
|
error: payload.error || payload.message,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
|
|||||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('IdeationService');
|
const logger = createLogger('IdeationService');
|
||||||
|
|
||||||
@@ -223,6 +223,13 @@ export class IdeationService {
|
|||||||
// Strip provider prefix - providers need bare model IDs
|
// Strip provider prefix - providers need bare model IDs
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
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 = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: message,
|
prompt: message,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
@@ -232,6 +239,8 @@ export class IdeationService {
|
|||||||
maxTurns: 1, // Single turn for ideation
|
maxTurns: 1, // Single turn for ideation
|
||||||
abortController: activeSession.abortController!,
|
abortController: activeSession.abortController!,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
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);
|
const stream = provider.executeQuery(executeOptions);
|
||||||
@@ -678,6 +687,13 @@ export class IdeationService {
|
|||||||
// Strip provider prefix - providers need bare model IDs
|
// Strip provider prefix - providers need bare model IDs
|
||||||
const bareModel = stripProviderPrefix(modelId);
|
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 = {
|
const executeOptions: ExecuteOptions = {
|
||||||
prompt: prompt.prompt,
|
prompt: prompt.prompt,
|
||||||
model: bareModel,
|
model: bareModel,
|
||||||
@@ -688,6 +704,8 @@ export class IdeationService {
|
|||||||
// Disable all tools - we just want text generation, not codebase analysis
|
// Disable all tools - we just want text generation, not codebase analysis
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
abortController: new AbortController(),
|
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);
|
const stream = provider.executeQuery(executeOptions);
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ import {
|
|||||||
CREDENTIALS_VERSION,
|
CREDENTIALS_VERSION,
|
||||||
PROJECT_SETTINGS_VERSION,
|
PROJECT_SETTINGS_VERSION,
|
||||||
} from '../types/settings.js';
|
} 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');
|
const logger = createLogger('SettingsService');
|
||||||
|
|
||||||
@@ -166,6 +171,41 @@ export class SettingsService {
|
|||||||
needsSave = true;
|
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
|
// Update version if any migration occurred
|
||||||
if (needsSave) {
|
if (needsSave) {
|
||||||
result.version = SETTINGS_VERSION;
|
result.version = SETTINGS_VERSION;
|
||||||
@@ -372,6 +412,7 @@ export class SettingsService {
|
|||||||
ignoreEmptyArrayOverwrite('recentFolders');
|
ignoreEmptyArrayOverwrite('recentFolders');
|
||||||
ignoreEmptyArrayOverwrite('mcpServers');
|
ignoreEmptyArrayOverwrite('mcpServers');
|
||||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||||
|
ignoreEmptyArrayOverwrite('claudeApiProfiles');
|
||||||
|
|
||||||
// Empty object overwrite guard
|
// Empty object overwrite guard
|
||||||
if (
|
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);
|
await writeSettingsJson(settingsPath, updated);
|
||||||
logger.info(`Project settings updated for ${projectPath}`);
|
logger.info(`Project settings updated for ${projectPath}`);
|
||||||
|
|
||||||
@@ -682,7 +734,7 @@ export class SettingsService {
|
|||||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY,
|
||||||
defaultSkipTests:
|
defaultSkipTests:
|
||||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||||
enableDependencyBlocking:
|
enableDependencyBlocking:
|
||||||
|
|||||||
@@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => {
|
|||||||
|
|
||||||
expect(result).toBe('Plain text');
|
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', () => {
|
describe('parseResetTime', () => {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@
|
|||||||
"productName": "Automaker",
|
"productName": "Automaker",
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"npmRebuild": false,
|
"npmRebuild": false,
|
||||||
|
"publish": null,
|
||||||
"afterPack": "./scripts/rebuild-server-natives.cjs",
|
"afterPack": "./scripts/rebuild-server-natives.cjs",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
|
|||||||
@@ -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 type { LucideIcon } from 'lucide-react';
|
||||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -6,35 +6,67 @@ import { cn } from '@/lib/utils';
|
|||||||
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import type { Project } from '@/lib/electron';
|
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';
|
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;
|
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 = {
|
const Z_INDEX = {
|
||||||
|
/** Base z-index for the main context menu */
|
||||||
CONTEXT_MENU: 100,
|
CONTEXT_MENU: 100,
|
||||||
|
/** Higher z-index for theme submenu to appear above parent menu */
|
||||||
THEME_SUBMENU: 101,
|
THEME_SUBMENU: 101,
|
||||||
} as const;
|
} 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 {
|
interface ThemeOption {
|
||||||
|
/** The theme mode value (e.g., 'dark', 'light', 'dracula') */
|
||||||
value: ThemeMode;
|
value: ThemeMode;
|
||||||
|
/** Display label for the theme option */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Lucide icon component to display alongside the label */
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
/** CSS color value for the icon */
|
||||||
color: string;
|
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 {
|
interface ThemeButtonProps {
|
||||||
|
/** The theme option data to display */
|
||||||
option: ThemeOption;
|
option: ThemeOption;
|
||||||
|
/** Whether this theme is currently selected */
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
/** Handler for pointer enter events (used for preview) */
|
||||||
onPointerEnter: () => void;
|
onPointerEnter: () => void;
|
||||||
|
/** Handler for pointer leave events (used to clear preview) */
|
||||||
onPointerLeave: (e: React.PointerEvent) => void;
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
|
/** Handler for click events (used to select theme) */
|
||||||
onClick: () => void;
|
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({
|
const ThemeButton = memo(function ThemeButton({
|
||||||
option,
|
option,
|
||||||
isSelected,
|
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 {
|
interface ThemeColumnProps {
|
||||||
|
/** Column header title (e.g., "Dark", "Light") */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Icon to display in the column header */
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
/** Array of theme options to display in this column */
|
||||||
themes: ThemeOption[];
|
themes: ThemeOption[];
|
||||||
|
/** Currently selected theme value, or null if using global theme */
|
||||||
selectedTheme: ThemeMode | null;
|
selectedTheme: ThemeMode | null;
|
||||||
|
/** Handler called when user hovers over a theme option for preview */
|
||||||
onPreviewEnter: (value: ThemeMode) => void;
|
onPreviewEnter: (value: ThemeMode) => void;
|
||||||
|
/** Handler called when user stops hovering over a theme option */
|
||||||
onPreviewLeave: (e: React.PointerEvent) => void;
|
onPreviewLeave: (e: React.PointerEvent) => void;
|
||||||
|
/** Handler called when user clicks to select a theme */
|
||||||
onSelect: (value: ThemeMode) => void;
|
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({
|
const ThemeColumn = memo(function ThemeColumn({
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
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 {
|
interface ProjectContextMenuProps {
|
||||||
|
/** The project this context menu is for */
|
||||||
project: Project;
|
project: Project;
|
||||||
|
/** Screen coordinates where the context menu should appear */
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
|
/** Callback to close the context menu */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Callback when user selects "Edit Name & Icon" option */
|
||||||
onEdit: (project: Project) => void;
|
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({
|
export function ProjectContextMenu({
|
||||||
project,
|
project,
|
||||||
position,
|
position,
|
||||||
@@ -130,9 +201,82 @@ export function ProjectContextMenu({
|
|||||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||||
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
||||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: globalThis.MouseEvent) => {
|
const handleClickOutside = (event: globalThis.MouseEvent) => {
|
||||||
// Don't close if a confirmation dialog is open (dialog is in a portal)
|
// Don't close if a confirmation dialog is open (dialog is in a portal)
|
||||||
@@ -242,11 +386,8 @@ export function ProjectContextMenu({
|
|||||||
{/* Theme Submenu Trigger */}
|
{/* Theme Submenu Trigger */}
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
onMouseEnter={handleThemeMenuEnter}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={handleThemeMenuLeave}
|
||||||
setShowThemeSubmenu(false);
|
|
||||||
setPreviewTheme(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||||
@@ -273,13 +414,18 @@ export function ProjectContextMenu({
|
|||||||
<div
|
<div
|
||||||
ref={themeSubmenuRef}
|
ref={themeSubmenuRef}
|
||||||
className={cn(
|
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',
|
'bg-popover text-popover-foreground',
|
||||||
'border border-border shadow-lg',
|
'border border-border shadow-lg',
|
||||||
'animate-in fade-in zoom-in-95 duration-100'
|
'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"
|
data-testid="project-theme-submenu"
|
||||||
|
onMouseEnter={handleThemeMenuEnter}
|
||||||
|
onMouseLeave={handleThemeMenuLeave}
|
||||||
>
|
>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{/* Use Global Option */}
|
{/* Use Global Option */}
|
||||||
@@ -306,7 +452,13 @@ export function ProjectContextMenu({
|
|||||||
<div className="h-px bg-border my-2" />
|
<div className="h-px bg-border my-2" />
|
||||||
|
|
||||||
{/* Two Column Layout - Using reusable ThemeColumn component */}
|
{/* 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
|
<ThemeColumn
|
||||||
title="Dark"
|
title="Dark"
|
||||||
icon={Moon}
|
icon={Moon}
|
||||||
|
|||||||
@@ -30,17 +30,41 @@ import {
|
|||||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { SortableProjectItem, ThemeMenuItem } from './';
|
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 { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
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 {
|
interface ProjectSelectorWithOptionsProps {
|
||||||
|
/** Whether the sidebar is currently expanded */
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
/** Whether the project picker dropdown is currently open */
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
|
/** Callback to control the project picker dropdown open state */
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
/** Callback to show the delete project confirmation dialog */
|
||||||
setShowDeleteProjectDialog: (show: boolean) => void;
|
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({
|
export function ProjectSelectorWithOptions({
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
@@ -246,6 +270,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||||
data-testid="project-theme-menu"
|
data-testid="project-theme-menu"
|
||||||
|
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
// Clear preview theme when leaving the dropdown
|
// Clear preview theme when leaving the dropdown
|
||||||
setPreviewTheme(null);
|
setPreviewTheme(null);
|
||||||
@@ -286,7 +311,8 @@ export function ProjectSelectorWithOptions({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{/* Two Column Layout */}
|
{/* 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 */}
|
{/* Dark Themes Column */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
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) => ({
|
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ const logger = createLogger('Board');
|
|||||||
export function BoardView() {
|
export function BoardView() {
|
||||||
const {
|
const {
|
||||||
currentProject,
|
currentProject,
|
||||||
maxConcurrency,
|
maxConcurrency: legacyMaxConcurrency,
|
||||||
setMaxConcurrency,
|
setMaxConcurrency: legacySetMaxConcurrency,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
@@ -275,10 +275,24 @@ export function BoardView() {
|
|||||||
setFeaturesWithContext,
|
setFeaturesWithContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto mode hook
|
// Load pipeline config when project changes
|
||||||
const autoMode = useAutoMode();
|
useEffect(() => {
|
||||||
// Get runningTasks from the hook (scoped to current project)
|
if (!currentProject?.path) return;
|
||||||
const runningAutoTasks = autoMode.runningTasks;
|
|
||||||
|
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
|
// Window state hook for compact dialog mode
|
||||||
const { isMaximized } = useWindowState();
|
const { isMaximized } = useWindowState();
|
||||||
@@ -388,14 +402,6 @@ export function BoardView() {
|
|||||||
[hookFeatures, updateFeature, persistFeatureUpdate]
|
[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
|
// Get current worktree info (path) for filtering features
|
||||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
@@ -421,6 +427,16 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
}, [worktrees, currentWorktreePath]);
|
}, [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)
|
// Get the current branch from the selected worktree (not from store which may be stale)
|
||||||
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
|
||||||
|
|
||||||
@@ -429,6 +445,15 @@ export function BoardView() {
|
|||||||
const selectedWorktreeBranch =
|
const selectedWorktreeBranch =
|
||||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
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
|
// Calculate unarchived card counts per branch
|
||||||
const branchCardCounts = useMemo(() => {
|
const branchCardCounts = useMemo(() => {
|
||||||
// Use primary worktree branch as default for features without branchName
|
// Use primary worktree branch as default for features without branchName
|
||||||
@@ -526,14 +551,14 @@ export function BoardView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine final branch name based on work mode:
|
// 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
|
// - 'auto': Auto-generate branch name based on current branch
|
||||||
// - 'custom': Use the provided branch name
|
// - 'custom': Use the provided branch name
|
||||||
let finalBranchName: string | undefined;
|
let finalBranchName: string | undefined;
|
||||||
|
|
||||||
if (workMode === 'current') {
|
if (workMode === 'current') {
|
||||||
// Empty string clears the branch assignment, moving features to main/current branch
|
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
|
||||||
finalBranchName = '';
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
// Always use primary branch to avoid nested feature/feature/... paths
|
||||||
@@ -619,6 +644,7 @@ export function BoardView() {
|
|||||||
exitSelectionMode,
|
exitSelectionMode,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
addAndSelectWorktree,
|
addAndSelectWorktree,
|
||||||
|
currentWorktreeBranch,
|
||||||
setWorktreeRefreshKey,
|
setWorktreeRefreshKey,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1139,7 +1165,21 @@ export function BoardView() {
|
|||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
runningAgentsCount={runningAutoTasks.length}
|
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}
|
isAutoModeRunning={autoMode.isRunning}
|
||||||
onAutoModeToggle={(enabled) => {
|
onAutoModeToggle={(enabled) => {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -1406,6 +1446,7 @@ export function BoardView() {
|
|||||||
featureId={outputFeature?.id || ''}
|
featureId={outputFeature?.id || ''}
|
||||||
featureStatus={outputFeature?.status}
|
featureStatus={outputFeature?.status}
|
||||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||||
|
branchName={outputFeature?.branchName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Archive All Verified Dialog */}
|
{/* Archive All Verified Dialog */}
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ export function BoardHeader({
|
|||||||
>
|
>
|
||||||
Auto Mode
|
Auto Mode
|
||||||
</Label>
|
</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
|
<Switch
|
||||||
id="auto-mode-toggle"
|
id="auto-mode-toggle"
|
||||||
checked={isAutoModeRunning}
|
checked={isAutoModeRunning}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface AgentOutputModalProps {
|
|||||||
onNumberKeyPress?: (key: string) => void;
|
onNumberKeyPress?: (key: string) => void;
|
||||||
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
/** Branch name for the feature worktree - used when viewing changes */
|
||||||
|
branchName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||||
@@ -41,6 +43,7 @@ export function AgentOutputModal({
|
|||||||
featureStatus,
|
featureStatus,
|
||||||
onNumberKeyPress,
|
onNumberKeyPress,
|
||||||
projectPath: projectPathProp,
|
projectPath: projectPathProp,
|
||||||
|
branchName,
|
||||||
}: AgentOutputModalProps) {
|
}: AgentOutputModalProps) {
|
||||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ export function AgentOutputModal({
|
|||||||
{resolvedProjectPath ? (
|
{resolvedProjectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={resolvedProjectPath}
|
projectPath={resolvedProjectPath}
|
||||||
featureId={featureId}
|
featureId={branchName || featureId}
|
||||||
compact={false}
|
compact={false}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
className="border-0 rounded-lg"
|
className="border-0 rounded-lg"
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export { EditFeatureDialog } from './edit-feature-dialog';
|
|||||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||||
export { MassEditDialog } from './mass-edit-dialog';
|
export { MassEditDialog } from './mass-edit-dialog';
|
||||||
|
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">Auto Mode</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -123,14 +123,15 @@ export function useBoardActions({
|
|||||||
const workMode = featureData.workMode || 'current';
|
const workMode = featureData.workMode || 'current';
|
||||||
|
|
||||||
// Determine final branch name based on work mode:
|
// 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
|
// - 'auto': Auto-generate branch name based on current branch
|
||||||
// - 'custom': Use the provided branch name
|
// - 'custom': Use the provided branch name
|
||||||
let finalBranchName: string | undefined;
|
let finalBranchName: string | undefined;
|
||||||
|
|
||||||
if (workMode === 'current') {
|
if (workMode === 'current') {
|
||||||
// No worktree isolation - work directly on current branch
|
// Work directly on current branch - use the current worktree's branch if not on main
|
||||||
finalBranchName = undefined;
|
// This ensures features created on a non-main worktree are associated with that worktree
|
||||||
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
// Always use primary branch to avoid nested feature/feature/... paths
|
||||||
@@ -217,7 +218,7 @@ export function useBoardActions({
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api?.features?.generateTitle) {
|
if (api?.features?.generateTitle) {
|
||||||
api.features
|
api.features
|
||||||
.generateTitle(featureData.description)
|
.generateTitle(featureData.description, projectPath ?? undefined)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.success && result.title) {
|
if (result.success && result.title) {
|
||||||
const titleUpdates = {
|
const titleUpdates = {
|
||||||
@@ -250,10 +251,12 @@ export function useBoardActions({
|
|||||||
updateFeature,
|
updateFeature,
|
||||||
saveCategory,
|
saveCategory,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
projectPath,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
onWorktreeAutoSelect,
|
onWorktreeAutoSelect,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
features,
|
features,
|
||||||
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -287,7 +290,9 @@ export function useBoardActions({
|
|||||||
let finalBranchName: string | undefined;
|
let finalBranchName: string | undefined;
|
||||||
|
|
||||||
if (workMode === 'current') {
|
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') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
// Always use primary branch to avoid nested feature/feature/... paths
|
||||||
@@ -402,6 +407,7 @@ export function useBoardActions({
|
|||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
features,
|
features,
|
||||||
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,25 @@ export function useBoardColumnFeatures({
|
|||||||
// Historically, we forced "running" features into in_progress so they never disappeared
|
// 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
|
// 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.
|
// 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 (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 (status.startsWith('pipeline_')) {
|
||||||
if (!map[status]) map[status] = [];
|
if (!map[status]) map[status] = [];
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
}, [currentProject]);
|
}, [currentProject, loadFeatures]);
|
||||||
|
|
||||||
// Save a new category to the persisted categories file
|
// Save a new category to the persisted categories file
|
||||||
const saveCategory = useCallback(
|
const saveCategory = useCallback(
|
||||||
@@ -87,11 +87,33 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
|
|
||||||
const { removeRunningTask } = useAppStore.getState();
|
const { removeRunningTask } = useAppStore.getState();
|
||||||
const projectId = currentProject.id;
|
const projectId = currentProject.id;
|
||||||
|
const projectPath = currentProject.path;
|
||||||
|
|
||||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
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;
|
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)
|
// Play ding sound when feature is done (unless muted)
|
||||||
const { muteDoneSound } = useAppStore.getState();
|
const { muteDoneSound } = useAppStore.getState();
|
||||||
if (!muteDoneSound) {
|
if (!muteDoneSound) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||||
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
const logger = createLogger('EnhanceWithAI');
|
const logger = createLogger('EnhanceWithAI');
|
||||||
|
|
||||||
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
|
|||||||
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
|
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
|
||||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
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
|
// Enhancement model override
|
||||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||||
|
|
||||||
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
|
|||||||
value,
|
value,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
enhancementOverride.effectiveModel,
|
enhancementOverride.effectiveModel,
|
||||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
enhancementOverride.effectiveModelEntry.thinkingLevel,
|
||||||
|
currentProjectPath
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
if (result?.success && result.enhancedText) {
|
||||||
|
|||||||
@@ -132,11 +132,12 @@ export function DevServerLogsPanel({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent
|
<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"
|
data-testid="dev-server-logs-panel"
|
||||||
|
compact
|
||||||
>
|
>
|
||||||
{/* Compact Header */}
|
{/* 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">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle className="flex items-center gap-2 text-base">
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
<Terminal className="w-4 h-4 text-primary" />
|
<Terminal className="w-4 h-4 text-primary" />
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Copy,
|
Copy,
|
||||||
|
Eye,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Terminal,
|
Terminal,
|
||||||
SquarePlus,
|
SquarePlus,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
|
Zap,
|
||||||
|
Undo2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -56,12 +59,16 @@ interface WorktreeActionsDropdownProps {
|
|||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** When true, renders as a standalone button (not attached to another element) */
|
/** When true, renders as a standalone button (not attached to another element) */
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
|
/** Whether auto mode is running for this worktree */
|
||||||
|
isAutoModeRunning?: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||||
|
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||||
|
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -73,6 +80,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +96,15 @@ export function WorktreeActionsDropdown({
|
|||||||
devServerInfo,
|
devServerInfo,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
standalone = false,
|
standalone = false,
|
||||||
|
isAutoModeRunning = false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenInIntegratedTerminal,
|
onOpenInIntegratedTerminal,
|
||||||
onOpenInExternalTerminal,
|
onOpenInExternalTerminal,
|
||||||
|
onViewChanges,
|
||||||
|
onDiscardChanges,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
@@ -105,6 +116,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
|
onToggleAutoMode,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
@@ -214,6 +226,26 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<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}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
onClick={() => canPerformGitOps && onPull(worktree)}
|
||||||
@@ -408,6 +440,13 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<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 && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!gitRepoStatus.isGitRepo}
|
showTooltip={!gitRepoStatus.isGitRepo}
|
||||||
@@ -483,9 +522,30 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</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 && (
|
{!worktree.isMain && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface WorktreeTabProps {
|
|||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
|
/** Whether auto mode is running for this worktree */
|
||||||
|
isAutoModeRunning?: boolean;
|
||||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -40,6 +42,8 @@ interface WorktreeTabProps {
|
|||||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||||
|
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||||
|
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
@@ -51,6 +55,7 @@ interface WorktreeTabProps {
|
|||||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ export function WorktreeTab({
|
|||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
|
isAutoModeRunning = false,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -86,6 +92,8 @@ export function WorktreeTab({
|
|||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenInIntegratedTerminal,
|
onOpenInIntegratedTerminal,
|
||||||
onOpenInExternalTerminal,
|
onOpenInExternalTerminal,
|
||||||
|
onViewChanges,
|
||||||
|
onDiscardChanges,
|
||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
@@ -97,6 +105,7 @@ export function WorktreeTab({
|
|||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
|
onToggleAutoMode,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: JSX.Element | null = null;
|
||||||
@@ -332,6 +341,26 @@ export function WorktreeTab({
|
|||||||
</TooltipProvider>
|
</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
|
<WorktreeActionsDropdown
|
||||||
worktree={worktree}
|
worktree={worktree}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
@@ -343,12 +372,15 @@ export function WorktreeTab({
|
|||||||
isDevServerRunning={isDevServerRunning}
|
isDevServerRunning={isDevServerRunning}
|
||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
isAutoModeRunning={isAutoModeRunning}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
onOpenInEditor={onOpenInEditor}
|
onOpenInEditor={onOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||||
|
onViewChanges={onViewChanges}
|
||||||
|
onDiscardChanges={onDiscardChanges}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
@@ -360,6 +392,7 @@ export function WorktreeTab({
|
|||||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||||
onViewDevServerLogs={onViewDevServerLogs}
|
onViewDevServerLogs={onViewDevServerLogs}
|
||||||
onRunInitScript={onRunInitScript}
|
onRunInitScript={onRunInitScript}
|
||||||
|
onToggleAutoMode={onToggleAutoMode}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn, pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
WorktreeActionsDropdown,
|
WorktreeActionsDropdown,
|
||||||
BranchSwitchDropdown,
|
BranchSwitchDropdown,
|
||||||
} from './components';
|
} 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({
|
export function WorktreePanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -51,7 +55,6 @@ export function WorktreePanel({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
getWorktreeKey,
|
|
||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
getDevServerInfo,
|
getDevServerInfo,
|
||||||
handleStartDevServer,
|
handleStartDevServer,
|
||||||
@@ -90,10 +93,79 @@ export function WorktreePanel({
|
|||||||
features,
|
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
|
// Check if init script exists for the project using React Query
|
||||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
const hasInitScript = initScriptData?.exists ?? false;
|
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
|
// Log panel state management
|
||||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
@@ -161,6 +233,41 @@ export function WorktreePanel({
|
|||||||
[projectPath]
|
[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
|
// Handle opening the log panel for a specific worktree
|
||||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||||
setLogPanelWorktree(worktree);
|
setLogPanelWorktree(worktree);
|
||||||
@@ -224,12 +331,15 @@ export function WorktreePanel({
|
|||||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
|
onViewChanges={handleViewChanges}
|
||||||
|
onDiscardChanges={handleDiscardChanges}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
@@ -241,6 +351,7 @@ export function WorktreePanel({
|
|||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -274,6 +385,36 @@ export function WorktreePanel({
|
|||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -308,6 +449,7 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -319,6 +461,8 @@ export function WorktreePanel({
|
|||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
|
onViewChanges={handleViewChanges}
|
||||||
|
onDiscardChanges={handleDiscardChanges}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
@@ -330,6 +474,7 @@ export function WorktreePanel({
|
|||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -368,6 +513,7 @@ export function WorktreePanel({
|
|||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
@@ -379,6 +525,8 @@ export function WorktreePanel({
|
|||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
|
onViewChanges={handleViewChanges}
|
||||||
|
onDiscardChanges={handleDiscardChanges}
|
||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
@@ -390,6 +538,7 @@ export function WorktreePanel({
|
|||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
onRunInitScript={handleRunInitScript}
|
onRunInitScript={handleRunInitScript}
|
||||||
|
onToggleAutoMode={handleToggleAutoMode}
|
||||||
hasInitScript={hasInitScript}
|
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 */}
|
{/* Dev Server Logs Panel */}
|
||||||
<DevServerLogsPanel
|
<DevServerLogsPanel
|
||||||
open={logPanelOpen}
|
open={logPanelOpen}
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ export function GraphViewPage() {
|
|||||||
featureId={outputFeature?.id || ''}
|
featureId={outputFeature?.id || ''}
|
||||||
featureStatus={outputFeature?.status}
|
featureStatus={outputFeature?.status}
|
||||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||||
|
branchName={outputFeature?.branchName}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Backlog Plan Dialog */}
|
{/* Backlog Plan Dialog */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
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';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
export interface ProjectNavigationItem {
|
export interface ProjectNavigationItem {
|
||||||
@@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
|||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
|
{ id: 'claude', label: 'Claude', icon: Bot },
|
||||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
||||||
|
|
||||||
interface UseProjectSettingsViewOptions {
|
interface UseProjectSettingsViewOptions {
|
||||||
initialView?: ProjectSettingsViewId;
|
initialView?: ProjectSettingsViewId;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
import { ProjectClaudeSection } from './project-claude-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
@@ -84,6 +85,8 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectThemeSection project={currentProject} />;
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
|
case 'claude':
|
||||||
|
return <ProjectClaudeSection project={currentProject} />;
|
||||||
case 'danger':
|
case 'danger':
|
||||||
return (
|
return (
|
||||||
<DangerZoneSection
|
<DangerZoneSection
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export function RunningAgentsView() {
|
|||||||
}
|
}
|
||||||
featureId={selectedAgent.featureId}
|
featureId={selectedAgent.featureId}
|
||||||
featureStatus="running"
|
featureStatus="running"
|
||||||
|
branchName={selectedAgent.branchName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Spinner } from '@/components/ui/spinner';
|
||||||
import { ApiKeyField } from './api-key-field';
|
import { ApiKeyField } from './api-key-field';
|
||||||
import { buildProviderConfigs } from '@/config/api-providers';
|
import { buildProviderConfigs } from '@/config/api-providers';
|
||||||
@@ -101,9 +101,38 @@ export function ApiKeysSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* API Key Fields */}
|
{/* API Key Fields with contextual info */}
|
||||||
{providerConfigs.map((provider) => (
|
{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 */}
|
{/* Security Notice */}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
|
|||||||
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
||||||
import { SkillsSection } from './claude-settings-tab/skills-section';
|
import { SkillsSection } from './claude-settings-tab/skills-section';
|
||||||
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
||||||
|
import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section';
|
||||||
import { ProviderToggle } from './provider-toggle';
|
import { ProviderToggle } from './provider-toggle';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
@@ -45,6 +46,10 @@ export function ClaudeSettingsTab() {
|
|||||||
isChecking={isCheckingClaudeCli}
|
isChecking={isCheckingClaudeCli}
|
||||||
onRefresh={handleRefreshClaudeCli}
|
onRefresh={handleRefreshClaudeCli}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* API Profiles for Claude-compatible endpoints */}
|
||||||
|
<ApiProfilesSection />
|
||||||
|
|
||||||
<ClaudeMdSettings
|
<ClaudeMdSettings
|
||||||
autoLoadClaudeMd={autoLoadClaudeMd}
|
autoLoadClaudeMd={autoLoadClaudeMd}
|
||||||
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
|
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
import { useEffect, useCallback, useMemo } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
|
||||||
|
|
||||||
const logger = createLogger('AutoMode');
|
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> {
|
function readAutoModeSession(): Record<string, boolean> {
|
||||||
try {
|
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 current = readAutoModeSession();
|
||||||
const next = { ...current, [projectPath]: running };
|
const next = { ...current, [worktreeKey]: running };
|
||||||
writeAutoModeSession(next);
|
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 {
|
const {
|
||||||
autoModeByProject,
|
autoModeByWorktree,
|
||||||
setAutoModeRunning,
|
setAutoModeRunning,
|
||||||
addRunningTask,
|
addRunningTask,
|
||||||
removeRunningTask,
|
removeRunningTask,
|
||||||
currentProject,
|
currentProject,
|
||||||
addAutoModeActivity,
|
addAutoModeActivity,
|
||||||
maxConcurrency,
|
|
||||||
projects,
|
projects,
|
||||||
setPendingPlanApproval,
|
setPendingPlanApproval,
|
||||||
|
getWorktreeKey,
|
||||||
|
getMaxConcurrencyForWorktree,
|
||||||
|
setMaxConcurrencyForWorktree,
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
autoModeByProject: state.autoModeByProject,
|
autoModeByWorktree: state.autoModeByWorktree,
|
||||||
setAutoModeRunning: state.setAutoModeRunning,
|
setAutoModeRunning: state.setAutoModeRunning,
|
||||||
addRunningTask: state.addRunningTask,
|
addRunningTask: state.addRunningTask,
|
||||||
removeRunningTask: state.removeRunningTask,
|
removeRunningTask: state.removeRunningTask,
|
||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
addAutoModeActivity: state.addAutoModeActivity,
|
addAutoModeActivity: state.addAutoModeActivity,
|
||||||
maxConcurrency: state.maxConcurrency,
|
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
setPendingPlanApproval: state.setPendingPlanApproval,
|
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
|
// Helper to look up project ID from path
|
||||||
const getProjectIdFromPath = useCallback(
|
const getProjectIdFromPath = useCallback(
|
||||||
(path: string): string | undefined => {
|
(path: string): string | undefined => {
|
||||||
@@ -81,15 +108,30 @@ export function useAutoMode() {
|
|||||||
[projects]
|
[projects]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get project-specific auto mode state
|
// Get worktree-specific auto mode state
|
||||||
const projectId = currentProject?.id;
|
const projectId = currentProject?.id;
|
||||||
const projectAutoModeState = useMemo(() => {
|
const worktreeAutoModeState = useMemo(() => {
|
||||||
if (!projectId) return { isRunning: false, runningTasks: [] };
|
if (!projectId)
|
||||||
return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
|
return {
|
||||||
}, [autoModeByProject, projectId]);
|
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 isAutoModeRunning = worktreeAutoModeState.isRunning;
|
||||||
const runningAutoTasks = projectAutoModeState.runningTasks;
|
const runningAutoTasks = worktreeAutoModeState.runningTasks;
|
||||||
|
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||||
|
|
||||||
// Check if we can start a new task based on concurrency limit
|
// Check if we can start a new task based on concurrency limit
|
||||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||||
@@ -104,15 +146,17 @@ export function useAutoMode() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode?.status) return;
|
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) {
|
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||||
const backendIsRunning = result.isAutoLoopRunning;
|
const backendIsRunning = result.isAutoLoopRunning;
|
||||||
|
|
||||||
if (backendIsRunning !== isAutoModeRunning) {
|
if (backendIsRunning !== isAutoModeRunning) {
|
||||||
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||||
logger.info(
|
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);
|
setAutoModeRunning(currentProject.id, branchName, backendIsRunning);
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,9 +165,9 @@ export function useAutoMode() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
syncWithBackend();
|
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(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode) return;
|
if (!api?.autoMode) return;
|
||||||
@@ -131,8 +175,8 @@ export function useAutoMode() {
|
|||||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||||
logger.info('Event:', event);
|
logger.info('Event:', event);
|
||||||
|
|
||||||
// Events include projectPath from backend - use it to look up project ID
|
// Events include projectPath and branchName from backend
|
||||||
// Fall back to current projectId if not provided in event
|
// Use them to look up project ID and determine the worktree
|
||||||
let eventProjectId: string | undefined;
|
let eventProjectId: string | undefined;
|
||||||
if ('projectPath' in event && event.projectPath) {
|
if ('projectPath' in event && event.projectPath) {
|
||||||
eventProjectId = getProjectIdFromPath(event.projectPath);
|
eventProjectId = getProjectIdFromPath(event.projectPath);
|
||||||
@@ -144,6 +188,10 @@ export function useAutoMode() {
|
|||||||
eventProjectId = projectId;
|
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
|
// Skip event if we couldn't determine the project
|
||||||
if (!eventProjectId) {
|
if (!eventProjectId) {
|
||||||
logger.warn('Could not determine project for event:', event);
|
logger.warn('Could not determine project for event:', event);
|
||||||
@@ -153,23 +201,34 @@ export function useAutoMode() {
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'auto_mode_started':
|
case 'auto_mode_started':
|
||||||
// Backend started auto loop - update UI state
|
// Backend started auto loop - update UI state
|
||||||
logger.info('[AutoMode] Backend started auto loop for project');
|
{
|
||||||
if (eventProjectId) {
|
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
|
||||||
setAutoModeRunning(eventProjectId, true);
|
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;
|
break;
|
||||||
|
|
||||||
case 'auto_mode_stopped':
|
case 'auto_mode_stopped':
|
||||||
// Backend stopped auto loop - update UI state
|
// Backend stopped auto loop - update UI state
|
||||||
logger.info('[AutoMode] Backend stopped auto loop for project');
|
{
|
||||||
if (eventProjectId) {
|
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
|
||||||
setAutoModeRunning(eventProjectId, false);
|
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
|
||||||
|
if (eventProjectId) {
|
||||||
|
setAutoModeRunning(eventProjectId, eventBranchName, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'auto_mode_feature_start':
|
case 'auto_mode_feature_start':
|
||||||
if (event.featureId) {
|
if (event.featureId) {
|
||||||
addRunningTask(eventProjectId, event.featureId);
|
addRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||||
addAutoModeActivity({
|
addAutoModeActivity({
|
||||||
featureId: event.featureId,
|
featureId: event.featureId,
|
||||||
type: 'start',
|
type: 'start',
|
||||||
@@ -182,7 +241,7 @@ export function useAutoMode() {
|
|||||||
// Feature completed - remove from running tasks and UI will reload features on its own
|
// Feature completed - remove from running tasks and UI will reload features on its own
|
||||||
if (event.featureId) {
|
if (event.featureId) {
|
||||||
logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
|
logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
|
||||||
removeRunningTask(eventProjectId, event.featureId);
|
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||||
addAutoModeActivity({
|
addAutoModeActivity({
|
||||||
featureId: event.featureId,
|
featureId: event.featureId,
|
||||||
type: 'complete',
|
type: 'complete',
|
||||||
@@ -202,7 +261,7 @@ export function useAutoMode() {
|
|||||||
logger.info('Feature cancelled/aborted:', event.error);
|
logger.info('Feature cancelled/aborted:', event.error);
|
||||||
// Remove from running tasks
|
// Remove from running tasks
|
||||||
if (eventProjectId) {
|
if (eventProjectId) {
|
||||||
removeRunningTask(eventProjectId, event.featureId);
|
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -229,7 +288,7 @@ export function useAutoMode() {
|
|||||||
|
|
||||||
// Remove the task from running since it failed
|
// Remove the task from running since it failed
|
||||||
if (eventProjectId) {
|
if (eventProjectId) {
|
||||||
removeRunningTask(eventProjectId, event.featureId);
|
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -404,9 +463,11 @@ export function useAutoMode() {
|
|||||||
setPendingPlanApproval,
|
setPendingPlanApproval,
|
||||||
setAutoModeRunning,
|
setAutoModeRunning,
|
||||||
currentProject?.path,
|
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 () => {
|
const start = useCallback(async () => {
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
logger.error('No project selected');
|
logger.error('No project selected');
|
||||||
@@ -419,36 +480,35 @@ export function useAutoMode() {
|
|||||||
throw new Error('Start auto mode API not available');
|
throw new Error('Start auto mode API not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||||
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
|
logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
|
||||||
);
|
|
||||||
|
|
||||||
// Optimistically update UI state (backend will confirm via event)
|
// Optimistically update UI state (backend will confirm via event)
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||||
setAutoModeRunning(currentProject.id, true);
|
setAutoModeRunning(currentProject.id, branchName, true);
|
||||||
|
|
||||||
// Call backend to start the auto loop
|
// Call backend to start the auto loop (backend uses stored concurrency)
|
||||||
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
|
const result = await api.autoMode.start(currentProject.path, branchName);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Revert UI state on failure
|
// Revert UI state on failure
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||||
setAutoModeRunning(currentProject.id, false);
|
setAutoModeRunning(currentProject.id, branchName, false);
|
||||||
logger.error('Failed to start auto mode:', result.error);
|
logger.error('Failed to start auto mode:', result.error);
|
||||||
throw new Error(result.error || 'Failed to start auto mode');
|
throw new Error(result.error || 'Failed to start auto mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`[AutoMode] Started successfully`);
|
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Revert UI state on error
|
// Revert UI state on error
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||||
setAutoModeRunning(currentProject.id, false);
|
setAutoModeRunning(currentProject.id, branchName, false);
|
||||||
logger.error('Error starting auto mode:', error);
|
logger.error('Error starting auto mode:', error);
|
||||||
throw 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 () => {
|
const stop = useCallback(async () => {
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
logger.error('No project selected');
|
logger.error('No project selected');
|
||||||
@@ -461,34 +521,35 @@ export function useAutoMode() {
|
|||||||
throw new Error('Stop auto mode API not available');
|
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)
|
// Optimistically update UI state (backend will confirm via event)
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, false);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
|
||||||
setAutoModeRunning(currentProject.id, false);
|
setAutoModeRunning(currentProject.id, branchName, false);
|
||||||
|
|
||||||
// Call backend to stop the auto loop
|
// 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) {
|
if (!result.success) {
|
||||||
// Revert UI state on failure
|
// Revert UI state on failure
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||||
setAutoModeRunning(currentProject.id, true);
|
setAutoModeRunning(currentProject.id, branchName, true);
|
||||||
logger.error('Failed to stop auto mode:', result.error);
|
logger.error('Failed to stop auto mode:', result.error);
|
||||||
throw new Error(result.error || 'Failed to stop auto mode');
|
throw new Error(result.error || 'Failed to stop auto mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Running tasks will continue until natural completion.
|
// NOTE: Running tasks will continue until natural completion.
|
||||||
// The backend stops picking up new features but doesn't abort running ones.
|
// 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) {
|
} catch (error) {
|
||||||
// Revert UI state on error
|
// Revert UI state on error
|
||||||
setAutoModeSessionForProjectPath(currentProject.path, true);
|
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
|
||||||
setAutoModeRunning(currentProject.id, true);
|
setAutoModeRunning(currentProject.id, branchName, true);
|
||||||
logger.error('Error stopping auto mode:', error);
|
logger.error('Error stopping auto mode:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [currentProject, setAutoModeRunning]);
|
}, [currentProject, branchName, setAutoModeRunning]);
|
||||||
|
|
||||||
// Stop a specific feature
|
// Stop a specific feature
|
||||||
const stopFeature = useCallback(
|
const stopFeature = useCallback(
|
||||||
@@ -507,7 +568,7 @@ export function useAutoMode() {
|
|||||||
const result = await api.autoMode.stopFeature(featureId);
|
const result = await api.autoMode.stopFeature(featureId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
removeRunningTask(currentProject.id, featureId);
|
removeRunningTask(currentProject.id, branchName, featureId);
|
||||||
logger.info('Feature stopped successfully:', featureId);
|
logger.info('Feature stopped successfully:', featureId);
|
||||||
addAutoModeActivity({
|
addAutoModeActivity({
|
||||||
featureId,
|
featureId,
|
||||||
@@ -524,7 +585,7 @@ export function useAutoMode() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, removeRunningTask, addAutoModeActivity]
|
[currentProject, branchName, removeRunningTask, addAutoModeActivity]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -532,6 +593,7 @@ export function useAutoMode() {
|
|||||||
runningTasks: runningAutoTasks,
|
runningTasks: runningAutoTasks,
|
||||||
maxConcurrency,
|
maxConcurrency,
|
||||||
canStartNewTask,
|
canStartNewTask,
|
||||||
|
branchName,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
stopFeature,
|
stopFeature,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
|
|||||||
const setAutoDismissInitScriptIndicator = useAppStore(
|
const setAutoDismissInitScriptIndicator = useAppStore(
|
||||||
(state) => state.setAutoDismissInitScriptIndicator
|
(state) => state.setAutoDismissInitScriptIndicator
|
||||||
);
|
);
|
||||||
|
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
|
||||||
|
|
||||||
const appliedProjectRef = useRef<string | null>(null);
|
const appliedProjectRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -90,6 +91,21 @@ export function useProjectSettingsLoader() {
|
|||||||
if (settings.autoDismissInitScriptIndicator !== undefined) {
|
if (settings.autoDismissInitScriptIndicator !== undefined) {
|
||||||
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
|
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,
|
currentProject?.path,
|
||||||
settings,
|
settings,
|
||||||
@@ -105,5 +121,6 @@ export function useProjectSettingsLoader() {
|
|||||||
setShowInitScriptIndicator,
|
setShowInitScriptIndicator,
|
||||||
setDefaultDeleteBranch,
|
setDefaultDeleteBranch,
|
||||||
setAutoDismissInitScriptIndicator,
|
setAutoDismissInitScriptIndicator,
|
||||||
|
setCurrentProject,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
|
|||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
getAllOpencodeModelIds,
|
getAllOpencodeModelIds,
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
migrateCursorModelIds,
|
migrateCursorModelIds,
|
||||||
@@ -194,6 +195,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||||
|
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||||
projects: state.projects as GlobalSettings['projects'],
|
projects: state.projects as GlobalSettings['projects'],
|
||||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
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),
|
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
||||||
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
||||||
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders 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) {
|
} catch (error) {
|
||||||
logger.error('Failed to parse localStorage settings:', error);
|
logger.error('Failed to parse localStorage settings:', error);
|
||||||
@@ -326,6 +332,20 @@ export function mergeSettings(
|
|||||||
merged.currentProjectId = localSettings.currentProjectId;
|
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;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,13 +655,39 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
setItem(THEME_STORAGE_KEY, storedTheme);
|
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({
|
useAppStore.setState({
|
||||||
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
|
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
|
||||||
fontFamilySans: settings.fontFamilySans ?? null,
|
fontFamilySans: settings.fontFamilySans ?? null,
|
||||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||||
sidebarOpen: settings.sidebarOpen ?? true,
|
sidebarOpen: settings.sidebarOpen ?? true,
|
||||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||||
maxConcurrency: settings.maxConcurrency ?? 3,
|
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||||
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
defaultSkipTests: settings.defaultSkipTests ?? true,
|
defaultSkipTests: settings.defaultSkipTests ?? true,
|
||||||
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
|
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
|
||||||
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
|
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
|
||||||
@@ -671,6 +717,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
},
|
},
|
||||||
mcpServers: settings.mcpServers ?? [],
|
mcpServers: settings.mcpServers ?? [],
|
||||||
promptCustomization: settings.promptCustomization ?? {},
|
promptCustomization: settings.promptCustomization ?? {},
|
||||||
|
eventHooks: settings.eventHooks ?? [],
|
||||||
|
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||||
|
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||||
projects,
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
trashedProjects: settings.trashedProjects ?? [],
|
trashedProjects: settings.trashedProjects ?? [],
|
||||||
@@ -705,6 +754,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||||
const state = useAppStore.getState();
|
const state = useAppStore.getState();
|
||||||
const setupState = useSetupStore.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 {
|
return {
|
||||||
setupComplete: setupState.setupComplete,
|
setupComplete: setupState.setupComplete,
|
||||||
isFirstRun: setupState.isFirstRun,
|
isFirstRun: setupState.isFirstRun,
|
||||||
@@ -713,6 +775,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
chatHistoryOpen: state.chatHistoryOpen,
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
|
autoModeByWorktree: persistedAutoModeByWorktree,
|
||||||
defaultSkipTests: state.defaultSkipTests,
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||||
@@ -732,6 +795,9 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
mcpServers: state.mcpServers,
|
mcpServers: state.mcpServers,
|
||||||
promptCustomization: state.promptCustomization,
|
promptCustomization: state.promptCustomization,
|
||||||
|
eventHooks: state.eventHooks,
|
||||||
|
claudeApiProfiles: state.claudeApiProfiles,
|
||||||
|
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
trashedProjects: state.trashedProjects,
|
trashedProjects: state.trashedProjects,
|
||||||
currentProjectId: state.currentProject?.id ?? null,
|
currentProjectId: state.currentProject?.id ?? null,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useAuthStore } from '@/store/auth-store';
|
|||||||
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
getAllOpencodeModelIds,
|
getAllOpencodeModelIds,
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
migrateCursorModelIds,
|
migrateCursorModelIds,
|
||||||
@@ -46,6 +47,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'sidebarOpen',
|
'sidebarOpen',
|
||||||
'chatHistoryOpen',
|
'chatHistoryOpen',
|
||||||
'maxConcurrency',
|
'maxConcurrency',
|
||||||
|
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
||||||
'defaultSkipTests',
|
'defaultSkipTests',
|
||||||
'enableDependencyBlocking',
|
'enableDependencyBlocking',
|
||||||
'skipVerificationInAutoMode',
|
'skipVerificationInAutoMode',
|
||||||
@@ -72,6 +74,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'defaultTerminalId',
|
'defaultTerminalId',
|
||||||
'promptCustomization',
|
'promptCustomization',
|
||||||
'eventHooks',
|
'eventHooks',
|
||||||
|
'claudeApiProfiles',
|
||||||
|
'activeClaudeApiProfileId',
|
||||||
'projects',
|
'projects',
|
||||||
'trashedProjects',
|
'trashedProjects',
|
||||||
'currentProjectId', // ID of currently open project
|
'currentProjectId', // ID of currently open project
|
||||||
@@ -112,6 +116,19 @@ function getSettingsFieldValue(
|
|||||||
if (field === 'openTerminalMode') {
|
if (field === 'openTerminalMode') {
|
||||||
return appState.terminalState.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];
|
return appState[field as keyof typeof appState];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,11 +608,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
setItem(THEME_STORAGE_KEY, serverSettings.theme);
|
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({
|
useAppStore.setState({
|
||||||
theme: serverSettings.theme as unknown as ThemeMode,
|
theme: serverSettings.theme as unknown as ThemeMode,
|
||||||
sidebarOpen: serverSettings.sidebarOpen,
|
sidebarOpen: serverSettings.sidebarOpen,
|
||||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||||
maxConcurrency: serverSettings.maxConcurrency,
|
maxConcurrency: serverSettings.maxConcurrency,
|
||||||
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
defaultSkipTests: serverSettings.defaultSkipTests,
|
defaultSkipTests: serverSettings.defaultSkipTests,
|
||||||
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
||||||
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
||||||
@@ -628,6 +671,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||||
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
|
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
|
||||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||||
|
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],
|
||||||
|
activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null,
|
||||||
projects: serverSettings.projects,
|
projects: serverSettings.projects,
|
||||||
trashedProjects: serverSettings.trashedProjects,
|
trashedProjects: serverSettings.trashedProjects,
|
||||||
projectHistory: serverSettings.projectHistory,
|
projectHistory: serverSettings.projectHistory,
|
||||||
@@ -637,6 +682,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||||
recentFolders: serverSettings.recentFolders ?? [],
|
recentFolders: serverSettings.recentFolders ?? [],
|
||||||
|
// Event hooks
|
||||||
|
eventHooks: serverSettings.eventHooks ?? [],
|
||||||
// Terminal settings (nested in terminalState)
|
// Terminal settings (nested in terminalState)
|
||||||
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
|
||||||
terminalState: {
|
terminalState: {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
UpdateIdeaInput,
|
UpdateIdeaInput,
|
||||||
ConvertToFeatureOptions,
|
ConvertToFeatureOptions,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||||
import { getJSON, setJSON, removeItem } from './storage';
|
import { getJSON, setJSON, removeItem } from './storage';
|
||||||
|
|
||||||
// Re-export issue validation types for use in components
|
// Re-export issue validation types for use in components
|
||||||
@@ -479,20 +480,26 @@ export interface FeaturesAPI {
|
|||||||
featureId: string
|
featureId: string
|
||||||
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
||||||
generateTitle: (
|
generateTitle: (
|
||||||
description: string
|
description: string,
|
||||||
|
projectPath?: string
|
||||||
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoModeAPI {
|
export interface AutoModeAPI {
|
||||||
start: (
|
start: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
|
branchName?: string | null,
|
||||||
maxConcurrency?: number
|
maxConcurrency?: number
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
stop: (
|
stop: (
|
||||||
projectPath: string
|
projectPath: string,
|
||||||
|
branchName?: string | null
|
||||||
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
|
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
|
||||||
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
|
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
status: (projectPath?: string) => Promise<{
|
status: (
|
||||||
|
projectPath?: string,
|
||||||
|
branchName?: string | null
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
isAutoLoopRunning?: boolean;
|
isAutoLoopRunning?: boolean;
|
||||||
@@ -706,7 +713,8 @@ export interface ElectronAPI {
|
|||||||
originalText: string,
|
originalText: string,
|
||||||
enhancementMode: string,
|
enhancementMode: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
thinkingLevel?: string
|
thinkingLevel?: string,
|
||||||
|
projectPath?: string
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
enhancedText?: string;
|
enhancedText?: string;
|
||||||
@@ -2016,6 +2024,20 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
console.log('[Mock] Unsubscribing from init script events');
|
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;
|
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';
|
const featureId = 'auto-mode-0';
|
||||||
mockRunningFeatures.add(featureId);
|
mockRunningFeatures.add(featureId);
|
||||||
|
|
||||||
@@ -3173,7 +3197,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
|
|||||||
return { success: true, content: content || null };
|
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));
|
console.log('[Mock] Generating title for:', description.substring(0, 50));
|
||||||
// Mock title generation - just take first few words
|
// Mock title generation - just take first few words
|
||||||
const words = description.split(/\s+/).slice(0, 6).join(' ');
|
const words = description.split(/\s+/).slice(0, 6).join(' ');
|
||||||
@@ -3349,6 +3373,13 @@ export interface Project {
|
|||||||
isFavorite?: boolean; // Pin project to top of dashboard
|
isFavorite?: boolean; // Pin project to top of dashboard
|
||||||
icon?: string; // Lucide icon name for project identification
|
icon?: string; // Lucide icon name for project identification
|
||||||
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
|
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 {
|
export interface TrashedProject extends Project {
|
||||||
|
|||||||
@@ -1657,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post('/api/features/delete', { projectPath, featureId }),
|
this.post('/api/features/delete', { projectPath, featureId }),
|
||||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||||
this.post('/api/features/agent-output', { projectPath, featureId }),
|
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||||
generateTitle: (description: string) =>
|
generateTitle: (description: string, projectPath?: string) =>
|
||||||
this.post('/api/features/generate-title', { description }),
|
this.post('/api/features/generate-title', { description, projectPath }),
|
||||||
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial<Feature>) =>
|
||||||
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
|
||||||
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
bulkDelete: (projectPath: string, featureIds: string[]) =>
|
||||||
@@ -1667,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
autoMode: AutoModeAPI = {
|
autoMode: AutoModeAPI = {
|
||||||
start: (projectPath: string, maxConcurrency?: number) =>
|
start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) =>
|
||||||
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
|
this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }),
|
||||||
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
|
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 }),
|
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: (
|
runFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
@@ -1743,13 +1745,15 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
originalText: string,
|
originalText: string,
|
||||||
enhancementMode: string,
|
enhancementMode: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
thinkingLevel?: string
|
thinkingLevel?: string,
|
||||||
|
projectPath?: string
|
||||||
): Promise<EnhancePromptResult> =>
|
): Promise<EnhancePromptResult> =>
|
||||||
this.post('/api/enhance-prompt', {
|
this.post('/api/enhance-prompt', {
|
||||||
originalText,
|
originalText,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
projectPath,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1847,6 +1851,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
this.httpDelete('/api/worktree/init-script', { projectPath }),
|
||||||
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
|
||||||
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
|
||||||
|
discardChanges: (worktreePath: string) =>
|
||||||
|
this.post('/api/worktree/discard-changes', { worktreePath }),
|
||||||
onInitScriptEvent: (
|
onInitScriptEvent: (
|
||||||
callback: (event: {
|
callback: (event: {
|
||||||
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
||||||
import type { Project, TrashedProject } from '@/lib/electron';
|
import type { Project, TrashedProject } from '@/lib/electron';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { setItem, getItem } from '@/lib/storage';
|
import { setItem, getItem } from '@/lib/storage';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,7 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ServerLogLevel,
|
ServerLogLevel,
|
||||||
EventHook,
|
EventHook,
|
||||||
|
ClaudeApiProfile,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
@@ -38,6 +40,7 @@ import {
|
|||||||
getAllOpencodeModelIds,
|
getAllOpencodeModelIds,
|
||||||
DEFAULT_PHASE_MODELS,
|
DEFAULT_PHASE_MODELS,
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AppStore');
|
const logger = createLogger('AppStore');
|
||||||
@@ -626,16 +629,18 @@ export interface AppState {
|
|||||||
currentChatSession: ChatSession | null;
|
currentChatSession: ChatSession | null;
|
||||||
chatHistoryOpen: boolean;
|
chatHistoryOpen: boolean;
|
||||||
|
|
||||||
// Auto Mode (per-project state, keyed by project ID)
|
// Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
|
||||||
autoModeByProject: Record<
|
autoModeByWorktree: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
runningTasks: string[]; // Feature IDs being worked on
|
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[];
|
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
|
// Kanban Card Display Settings
|
||||||
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||||
@@ -747,6 +752,10 @@ export interface AppState {
|
|||||||
// Event Hooks
|
// Event Hooks
|
||||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||||
|
|
||||||
|
// Claude API Profiles
|
||||||
|
claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
|
||||||
|
activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
|
||||||
|
|
||||||
// Project Analysis
|
// Project Analysis
|
||||||
projectAnalysis: ProjectAnalysis | null;
|
projectAnalysis: ProjectAnalysis | null;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
@@ -1030,6 +1039,9 @@ export interface AppActions {
|
|||||||
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
|
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)
|
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
|
// Feature actions
|
||||||
setFeatures: (features: Feature[]) => void;
|
setFeatures: (features: Feature[]) => void;
|
||||||
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
updateFeature: (id: string, updates: Partial<Feature>) => void;
|
||||||
@@ -1057,18 +1069,36 @@ export interface AppActions {
|
|||||||
setChatHistoryOpen: (open: boolean) => void;
|
setChatHistoryOpen: (open: boolean) => void;
|
||||||
toggleChatHistory: () => void;
|
toggleChatHistory: () => void;
|
||||||
|
|
||||||
// Auto Mode actions (per-project)
|
// Auto Mode actions (per-worktree)
|
||||||
setAutoModeRunning: (projectId: string, running: boolean) => void;
|
setAutoModeRunning: (
|
||||||
addRunningTask: (projectId: string, taskId: string) => void;
|
projectId: string,
|
||||||
removeRunningTask: (projectId: string, taskId: string) => void;
|
branchName: string | null,
|
||||||
clearRunningTasks: (projectId: string) => void;
|
running: boolean,
|
||||||
getAutoModeState: (projectId: string) => {
|
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;
|
isRunning: boolean;
|
||||||
runningTasks: string[];
|
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;
|
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
|
||||||
clearAutoModeActivity: () => 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
|
// Kanban Card Settings actions
|
||||||
setBoardViewMode: (mode: BoardViewMode) => void;
|
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||||
@@ -1180,6 +1210,13 @@ export interface AppActions {
|
|||||||
// Event Hook actions
|
// Event Hook actions
|
||||||
setEventHooks: (hooks: EventHook[]) => void;
|
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
|
// MCP Server actions
|
||||||
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
||||||
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
||||||
@@ -1387,9 +1424,9 @@ const initialState: AppState = {
|
|||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
currentChatSession: null,
|
currentChatSession: null,
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
autoModeByProject: {},
|
autoModeByWorktree: {},
|
||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents
|
||||||
boardViewMode: 'kanban', // Default to kanban view
|
boardViewMode: 'kanban', // Default to kanban view
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
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
|
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
|
||||||
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
|
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
|
||||||
eventHooks: [], // No event hooks configured by default
|
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,
|
projectAnalysis: null,
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
boardBackgroundByProject: {},
|
boardBackgroundByProject: {},
|
||||||
@@ -1936,6 +1975,47 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS);
|
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
|
// Feature actions
|
||||||
setFeatures: (features) => set({ features }),
|
setFeatures: (features) => set({ features }),
|
||||||
|
|
||||||
@@ -2073,74 +2153,125 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
|
|
||||||
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
|
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
|
||||||
|
|
||||||
// Auto Mode actions (per-project)
|
// Auto Mode actions (per-worktree)
|
||||||
setAutoModeRunning: (projectId, running) => {
|
getWorktreeKey: (projectId, branchName) => {
|
||||||
const current = get().autoModeByProject;
|
return `${projectId}::${branchName ?? '__main__'}`;
|
||||||
const projectState = current[projectId] || {
|
},
|
||||||
|
|
||||||
|
setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => {
|
||||||
|
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||||
|
const current = get().autoModeByWorktree;
|
||||||
|
const worktreeState = current[worktreeKey] || {
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
|
branchName,
|
||||||
|
maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByWorktree: {
|
||||||
...current,
|
...current,
|
||||||
[projectId]: { ...projectState, isRunning: running },
|
[worktreeKey]: {
|
||||||
|
...worktreeState,
|
||||||
|
isRunning: running,
|
||||||
|
branchName,
|
||||||
|
maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
addRunningTask: (projectId, taskId) => {
|
addRunningTask: (projectId, branchName, taskId) => {
|
||||||
const current = get().autoModeByProject;
|
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||||
const projectState = current[projectId] || {
|
const current = get().autoModeByWorktree;
|
||||||
|
const worktreeState = current[worktreeKey] || {
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
|
branchName,
|
||||||
};
|
};
|
||||||
if (!projectState.runningTasks.includes(taskId)) {
|
if (!worktreeState.runningTasks.includes(taskId)) {
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByWorktree: {
|
||||||
...current,
|
...current,
|
||||||
[projectId]: {
|
[worktreeKey]: {
|
||||||
...projectState,
|
...worktreeState,
|
||||||
runningTasks: [...projectState.runningTasks, taskId],
|
runningTasks: [...worktreeState.runningTasks, taskId],
|
||||||
|
branchName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeRunningTask: (projectId, taskId) => {
|
removeRunningTask: (projectId, branchName, taskId) => {
|
||||||
const current = get().autoModeByProject;
|
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||||
const projectState = current[projectId] || {
|
const current = get().autoModeByWorktree;
|
||||||
|
const worktreeState = current[worktreeKey] || {
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
|
branchName,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByWorktree: {
|
||||||
...current,
|
...current,
|
||||||
[projectId]: {
|
[worktreeKey]: {
|
||||||
...projectState,
|
...worktreeState,
|
||||||
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
|
runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId),
|
||||||
|
branchName,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
clearRunningTasks: (projectId) => {
|
clearRunningTasks: (projectId, branchName) => {
|
||||||
const current = get().autoModeByProject;
|
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||||
const projectState = current[projectId] || {
|
const current = get().autoModeByWorktree;
|
||||||
|
const worktreeState = current[worktreeKey] || {
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
runningTasks: [],
|
runningTasks: [],
|
||||||
|
branchName,
|
||||||
};
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByWorktree: {
|
||||||
...current,
|
...current,
|
||||||
[projectId]: { ...projectState, runningTasks: [] },
|
[worktreeKey]: { ...worktreeState, runningTasks: [], branchName },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getAutoModeState: (projectId) => {
|
getAutoModeState: (projectId, branchName) => {
|
||||||
const projectState = get().autoModeByProject[projectId];
|
const worktreeKey = get().getWorktreeKey(projectId, branchName);
|
||||||
return projectState || { isRunning: false, runningTasks: [] };
|
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) => {
|
addAutoModeActivity: (activity) => {
|
||||||
@@ -2459,6 +2590,82 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Event Hook actions
|
// Event Hook actions
|
||||||
setEventHooks: (hooks) => set({ eventHooks: hooks }),
|
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
|
// MCP Server actions
|
||||||
addMCPServer: (server) => {
|
addMCPServer: (server) => {
|
||||||
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|||||||
68
apps/ui/src/types/electron.d.ts
vendored
68
apps/ui/src/types/electron.d.ts
vendored
@@ -163,11 +163,30 @@ export interface SessionsAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AutoModeEvent =
|
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';
|
type: 'auto_mode_feature_start';
|
||||||
featureId: string;
|
featureId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
branchName?: string | null;
|
||||||
feature: unknown;
|
feature: unknown;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -175,6 +194,7 @@ export type AutoModeEvent =
|
|||||||
featureId: string;
|
featureId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
branchName?: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -182,6 +202,7 @@ export type AutoModeEvent =
|
|||||||
featureId: string;
|
featureId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
branchName?: string | null;
|
||||||
tool: string;
|
tool: string;
|
||||||
input: unknown;
|
input: unknown;
|
||||||
}
|
}
|
||||||
@@ -190,6 +211,7 @@ export type AutoModeEvent =
|
|||||||
featureId: string;
|
featureId: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
branchName?: string | null;
|
||||||
passes: boolean;
|
passes: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -218,6 +240,7 @@ export type AutoModeEvent =
|
|||||||
featureId?: string;
|
featureId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
branchName?: string | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'auto_mode_phase';
|
type: 'auto_mode_phase';
|
||||||
@@ -389,18 +412,48 @@ export interface SpecRegenerationAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoModeAPI {
|
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<{
|
stopFeature: (featureId: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
status: (projectPath?: string) => Promise<{
|
status: (
|
||||||
|
projectPath?: string,
|
||||||
|
branchName?: string | null
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
|
isAutoLoopRunning?: boolean;
|
||||||
currentFeatureId?: string | null;
|
currentFeatureId?: string | null;
|
||||||
runningFeatures?: string[];
|
runningFeatures?: string[];
|
||||||
runningProjects?: string[];
|
runningProjects?: string[];
|
||||||
runningCount?: number;
|
runningCount?: number;
|
||||||
|
maxConcurrency?: number;
|
||||||
|
branchName?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -1165,6 +1218,19 @@ export interface WorktreeAPI {
|
|||||||
payload: unknown;
|
payload: unknown;
|
||||||
}) => void
|
}) => void
|
||||||
) => () => 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 {
|
export interface GitAPI {
|
||||||
|
|||||||
448
docs/UNIFIED_API_KEY_PROFILES.md
Normal file
448
docs/UNIFIED_API_KEY_PROFILES.md
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
# Unified Claude API Key and Profile System
|
||||||
|
|
||||||
|
This document describes the implementation of a unified API key sourcing system for Claude API profiles, allowing flexible configuration of how API keys are resolved.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Previously, Automaker had two separate systems for configuring Claude API access:
|
||||||
|
|
||||||
|
1. **API Keys section** (`credentials.json`): Stored Anthropic API key, used when no profile was active
|
||||||
|
2. **API Profiles section** (`settings.json`): Stored alternative endpoint configs (e.g., z.AI GLM) with their own inline API keys
|
||||||
|
|
||||||
|
This created several issues:
|
||||||
|
|
||||||
|
- Users configured Anthropic key in one place, but alternative endpoints in another
|
||||||
|
- No way to create a "Direct Anthropic" profile that reused the stored credentials
|
||||||
|
- Environment variable detection didn't integrate with the profile system
|
||||||
|
- Duplicated API key entry when users wanted the same key for multiple configurations
|
||||||
|
|
||||||
|
## Solution Overview
|
||||||
|
|
||||||
|
The solution introduces a flexible `apiKeySource` field on Claude API profiles that determines where the API key is resolved from:
|
||||||
|
|
||||||
|
| Source | Description |
|
||||||
|
| ------------- | ----------------------------------------------------------------- |
|
||||||
|
| `inline` | API key stored directly in the profile (legacy behavior, default) |
|
||||||
|
| `env` | Uses `ANTHROPIC_API_KEY` environment variable |
|
||||||
|
| `credentials` | Uses the Anthropic key from Settings → API Keys |
|
||||||
|
|
||||||
|
This allows:
|
||||||
|
|
||||||
|
- A single API key to be shared across multiple profile configurations
|
||||||
|
- "Direct Anthropic" profile that references saved credentials
|
||||||
|
- Environment variable support for CI/CD and containerized deployments
|
||||||
|
- Backwards compatibility with existing inline key profiles
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Type Changes
|
||||||
|
|
||||||
|
#### New Type: `ApiKeySource`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// libs/types/src/settings.ts
|
||||||
|
export type ApiKeySource = 'inline' | 'env' | 'credentials';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Updated Interface: `ClaudeApiProfile`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ClaudeApiProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
|
||||||
|
// NEW: API key sourcing strategy (default: 'inline' for backwards compat)
|
||||||
|
apiKeySource?: ApiKeySource;
|
||||||
|
|
||||||
|
// Now optional - only required when apiKeySource = 'inline'
|
||||||
|
apiKey?: string;
|
||||||
|
|
||||||
|
// Existing fields unchanged...
|
||||||
|
useAuthToken?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
modelMappings?: { haiku?: string; sonnet?: string; opus?: string };
|
||||||
|
disableNonessentialTraffic?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Updated Interface: `ClaudeApiProfileTemplate`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ClaudeApiProfileTemplate {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
defaultApiKeySource?: ApiKeySource; // NEW: Suggested source for this template
|
||||||
|
useAuthToken: boolean;
|
||||||
|
// ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Templates
|
||||||
|
|
||||||
|
The following provider templates are available:
|
||||||
|
|
||||||
|
#### Direct Anthropic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: 'Direct Anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
defaultApiKeySource: 'credentials',
|
||||||
|
useAuthToken: false,
|
||||||
|
description: 'Standard Anthropic API with your API key',
|
||||||
|
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OpenRouter
|
||||||
|
|
||||||
|
Access Claude and 300+ other models through OpenRouter's unified API.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: 'OpenRouter',
|
||||||
|
baseUrl: 'https://openrouter.ai/api',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
description: 'Access Claude and 300+ models via OpenRouter',
|
||||||
|
apiKeyUrl: 'https://openrouter.ai/keys',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Uses `ANTHROPIC_AUTH_TOKEN` with your OpenRouter API key
|
||||||
|
- No model mappings by default - OpenRouter auto-maps Anthropic models
|
||||||
|
- Can customize model mappings to use any OpenRouter-supported model (e.g., `openai/gpt-5.1-codex-max`)
|
||||||
|
|
||||||
|
#### z.AI GLM
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: 'z.AI GLM',
|
||||||
|
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'GLM-4.5-Air',
|
||||||
|
sonnet: 'GLM-4.7',
|
||||||
|
opus: 'GLM-4.7',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: '3× usage at fraction of cost via GLM Coding Plan',
|
||||||
|
apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MiniMax
|
||||||
|
|
||||||
|
MiniMax M2.1 coding model with extended context support.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: 'MiniMax',
|
||||||
|
baseUrl: 'https://api.minimax.io/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'MiniMax-M2.1',
|
||||||
|
sonnet: 'MiniMax-M2.1',
|
||||||
|
opus: 'MiniMax-M2.1',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: 'MiniMax M2.1 coding model with extended context',
|
||||||
|
apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MiniMax (China)
|
||||||
|
|
||||||
|
Same as MiniMax but using the China-region endpoint.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
name: 'MiniMax (China)',
|
||||||
|
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'MiniMax-M2.1',
|
||||||
|
sonnet: 'MiniMax-M2.1',
|
||||||
|
opus: 'MiniMax-M2.1',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: 'MiniMax M2.1 for users in China',
|
||||||
|
apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-Side Changes
|
||||||
|
|
||||||
|
#### 1. Environment Building (`claude-provider.ts`)
|
||||||
|
|
||||||
|
The `buildEnv()` function now resolves API keys based on the `apiKeySource`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildEnv(
|
||||||
|
profile?: ClaudeApiProfile,
|
||||||
|
credentials?: Credentials // NEW parameter
|
||||||
|
): Record<string, string | undefined> {
|
||||||
|
if (profile) {
|
||||||
|
// Resolve API key based on source strategy
|
||||||
|
let apiKey: string | undefined;
|
||||||
|
const source = profile.apiKeySource ?? 'inline';
|
||||||
|
|
||||||
|
switch (source) {
|
||||||
|
case 'inline':
|
||||||
|
apiKey = profile.apiKey;
|
||||||
|
break;
|
||||||
|
case 'env':
|
||||||
|
apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
break;
|
||||||
|
case 'credentials':
|
||||||
|
apiKey = credentials?.apiKeys?.anthropic;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of profile-based env building
|
||||||
|
}
|
||||||
|
// ... no-profile fallback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Settings Helper (`settings-helpers.ts`)
|
||||||
|
|
||||||
|
The `getActiveClaudeApiProfile()` function now returns both profile and credentials:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ActiveClaudeApiProfileResult {
|
||||||
|
profile: ClaudeApiProfile | undefined;
|
||||||
|
credentials: Credentials | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveClaudeApiProfile(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<ActiveClaudeApiProfileResult> {
|
||||||
|
// Returns both profile and credentials for API key resolution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Auto-Migration (`settings-service.ts`)
|
||||||
|
|
||||||
|
A v4→v5 migration automatically creates a "Direct Anthropic" profile for existing users:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile
|
||||||
|
if (storedVersion < 5) {
|
||||||
|
const credentials = await this.getCredentials();
|
||||||
|
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
|
||||||
|
const hasNoProfiles = !result.claudeApiProfiles?.length;
|
||||||
|
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
|
||||||
|
|
||||||
|
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
|
||||||
|
// Create "Direct Anthropic" profile with apiKeySource: 'credentials'
|
||||||
|
// and set it as active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Updated Call Sites
|
||||||
|
|
||||||
|
All files that call `getActiveClaudeApiProfile()` were updated to:
|
||||||
|
|
||||||
|
1. Destructure both `profile` and `credentials` from the result
|
||||||
|
2. Pass `credentials` to the provider via `ExecuteOptions`
|
||||||
|
|
||||||
|
**Files updated:**
|
||||||
|
|
||||||
|
- `apps/server/src/services/agent-service.ts`
|
||||||
|
- `apps/server/src/services/auto-mode-service.ts` (2 locations)
|
||||||
|
- `apps/server/src/services/ideation-service.ts` (2 locations)
|
||||||
|
- `apps/server/src/providers/simple-query-service.ts`
|
||||||
|
- `apps/server/src/routes/enhance-prompt/routes/enhance.ts`
|
||||||
|
- `apps/server/src/routes/context/routes/describe-file.ts`
|
||||||
|
- `apps/server/src/routes/context/routes/describe-image.ts`
|
||||||
|
- `apps/server/src/routes/github/routes/validate-issue.ts`
|
||||||
|
- `apps/server/src/routes/worktree/routes/generate-commit-message.ts`
|
||||||
|
- `apps/server/src/routes/features/routes/generate-title.ts`
|
||||||
|
- `apps/server/src/routes/backlog-plan/generate-plan.ts`
|
||||||
|
- `apps/server/src/routes/app-spec/sync-spec.ts`
|
||||||
|
- `apps/server/src/routes/app-spec/generate-features-from-spec.ts`
|
||||||
|
- `apps/server/src/routes/app-spec/generate-spec.ts`
|
||||||
|
- `apps/server/src/routes/suggestions/generate-suggestions.ts`
|
||||||
|
|
||||||
|
### UI Changes
|
||||||
|
|
||||||
|
#### 1. Profile Form (`api-profiles-section.tsx`)
|
||||||
|
|
||||||
|
Added an API Key Source selector dropdown:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select
|
||||||
|
value={formData.apiKeySource}
|
||||||
|
onValueChange={(value: ApiKeySource) => setFormData({ ...formData, apiKeySource: value })}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
The API Key input field is now conditionally rendered only when `apiKeySource === 'inline'`.
|
||||||
|
|
||||||
|
#### 2. API Keys Section (`api-keys-section.tsx`)
|
||||||
|
|
||||||
|
Added an informational note:
|
||||||
|
|
||||||
|
> API Keys saved here can be used by API Profiles with "credentials" as the API key source. This lets you share a single key across multiple profile configurations without re-entering it.
|
||||||
|
|
||||||
|
## User Flows
|
||||||
|
|
||||||
|
### New User Flow
|
||||||
|
|
||||||
|
1. Go to Settings → API Keys
|
||||||
|
2. Enter Anthropic API key and save
|
||||||
|
3. Go to Settings → Providers → Claude
|
||||||
|
4. Create new profile from "Direct Anthropic" template
|
||||||
|
5. API Key Source defaults to "credentials" - no need to re-enter key
|
||||||
|
6. Save profile and set as active
|
||||||
|
|
||||||
|
### Existing User Migration
|
||||||
|
|
||||||
|
When an existing user with an Anthropic API key (but no profiles) loads settings:
|
||||||
|
|
||||||
|
1. System detects v4→v5 migration needed
|
||||||
|
2. Automatically creates "Direct Anthropic" profile with `apiKeySource: 'credentials'`
|
||||||
|
3. Sets new profile as active
|
||||||
|
4. User's existing workflow continues to work seamlessly
|
||||||
|
|
||||||
|
### Environment Variable Flow
|
||||||
|
|
||||||
|
For CI/CD or containerized deployments:
|
||||||
|
|
||||||
|
1. Set `ANTHROPIC_API_KEY` in environment
|
||||||
|
2. Create profile with `apiKeySource: 'env'`
|
||||||
|
3. Profile will use the environment variable at runtime
|
||||||
|
|
||||||
|
## Backwards Compatibility
|
||||||
|
|
||||||
|
- Profiles without `apiKeySource` field default to `'inline'`
|
||||||
|
- Existing profiles with inline `apiKey` continue to work unchanged
|
||||||
|
- No changes to the credentials file format
|
||||||
|
- Settings version bumped from 4 to 5 (migration is additive)
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `libs/types/src/settings.ts` | Added `ApiKeySource` type, updated `ClaudeApiProfile`, added Direct Anthropic template |
|
||||||
|
| `libs/types/src/provider.ts` | Added `credentials` field to `ExecuteOptions` |
|
||||||
|
| `libs/types/src/index.ts` | Exported `ApiKeySource` type |
|
||||||
|
| `apps/server/src/providers/claude-provider.ts` | Updated `buildEnv()` to resolve keys from different sources |
|
||||||
|
| `apps/server/src/lib/settings-helpers.ts` | Updated return type to include credentials |
|
||||||
|
| `apps/server/src/services/settings-service.ts` | Added v4→v5 auto-migration |
|
||||||
|
| `apps/server/src/providers/simple-query-service.ts` | Added credentials passthrough |
|
||||||
|
| `apps/server/src/services/*.ts` | Updated to pass credentials |
|
||||||
|
| `apps/server/src/routes/**/*.ts` | Updated to pass credentials (15 files) |
|
||||||
|
| `apps/ui/src/.../api-profiles-section.tsx` | Added API Key Source selector |
|
||||||
|
| `apps/ui/src/.../api-keys-section.tsx` | Added profile usage note |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To verify the implementation:
|
||||||
|
|
||||||
|
1. **New user flow**: Create "Direct Anthropic" profile, select `credentials` source, enter key in API Keys section → verify it works
|
||||||
|
2. **Existing user migration**: User with credentials.json key sees auto-created "Direct Anthropic" profile
|
||||||
|
3. **Env var support**: Create profile with `env` source, set ANTHROPIC_API_KEY → verify it works
|
||||||
|
4. **z.AI GLM unchanged**: Existing profiles with inline keys continue working
|
||||||
|
5. **Backwards compat**: Profiles without `apiKeySource` field default to `inline`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
npm run build:packages
|
||||||
|
npm run dev:web
|
||||||
|
|
||||||
|
# Run server tests
|
||||||
|
npm run test:server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Per-Project Profile Override
|
||||||
|
|
||||||
|
Projects can override the global Claude API profile selection, allowing different projects to use different endpoints or configurations.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
In **Project Settings → Claude**, users can select:
|
||||||
|
|
||||||
|
| Option | Behavior |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------ |
|
||||||
|
| **Use Global Setting** | Inherits the active profile from global settings (default) |
|
||||||
|
| **Direct Anthropic API** | Explicitly uses direct Anthropic API, bypassing any global profile |
|
||||||
|
| **\<Profile Name\>** | Uses that specific profile for this project only |
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
The per-project setting is stored in `.automaker/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"activeClaudeApiProfileId": "profile-id-here"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `undefined` (or key absent): Use global setting
|
||||||
|
- `null`: Explicitly use Direct Anthropic API
|
||||||
|
- `"<id>"`: Use specific profile by ID
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
The `getActiveClaudeApiProfile()` function accepts an optional `projectPath` parameter:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getActiveClaudeApiProfile(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]',
|
||||||
|
projectPath?: string // Optional: check project settings first
|
||||||
|
): Promise<ActiveClaudeApiProfileResult>;
|
||||||
|
```
|
||||||
|
|
||||||
|
When `projectPath` is provided:
|
||||||
|
|
||||||
|
1. Project settings are checked first for `activeClaudeApiProfileId`
|
||||||
|
2. If project has a value (including `null`), that takes precedence
|
||||||
|
3. If project has no override (`undefined`), falls back to global setting
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
**Important:** Per-project profiles only affect Claude model calls. When other providers are used (Codex, OpenCode, Cursor), the Claude API profile setting has no effect—those providers use their own configuration.
|
||||||
|
|
||||||
|
Affected operations when using Claude models:
|
||||||
|
|
||||||
|
- Agent chat and feature implementation
|
||||||
|
- Code analysis and suggestions
|
||||||
|
- Commit message generation
|
||||||
|
- Spec generation and sync
|
||||||
|
- Issue validation
|
||||||
|
- Backlog planning
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
1. **Experimentation**: Test z.AI GLM or MiniMax on a side project while keeping production projects on Direct Anthropic
|
||||||
|
2. **Cost optimization**: Use cheaper endpoints for hobby projects, premium for work projects
|
||||||
|
3. **Regional compliance**: Use China endpoints for projects with data residency requirements
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential future improvements:
|
||||||
|
|
||||||
|
1. **UI indicators**: Show whether credentials/env key is configured when selecting those sources
|
||||||
|
2. **Validation**: Warn if selected source has no key configured
|
||||||
|
3. **Per-provider credentials**: Support different credential keys for different providers
|
||||||
|
4. **Key rotation**: Support for rotating keys without updating profiles
|
||||||
@@ -161,6 +161,10 @@ export type {
|
|||||||
EventHookHttpAction,
|
EventHookHttpAction,
|
||||||
EventHookAction,
|
EventHookAction,
|
||||||
EventHook,
|
EventHook,
|
||||||
|
// Claude API profile types
|
||||||
|
ApiKeySource,
|
||||||
|
ClaudeApiProfile,
|
||||||
|
ClaudeApiProfileTemplate,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_KEYBOARD_SHORTCUTS,
|
DEFAULT_KEYBOARD_SHORTCUTS,
|
||||||
@@ -168,6 +172,7 @@ export {
|
|||||||
DEFAULT_GLOBAL_SETTINGS,
|
DEFAULT_GLOBAL_SETTINGS,
|
||||||
DEFAULT_CREDENTIALS,
|
DEFAULT_CREDENTIALS,
|
||||||
DEFAULT_PROJECT_SETTINGS,
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
SETTINGS_VERSION,
|
SETTINGS_VERSION,
|
||||||
CREDENTIALS_VERSION,
|
CREDENTIALS_VERSION,
|
||||||
PROJECT_SETTINGS_VERSION,
|
PROJECT_SETTINGS_VERSION,
|
||||||
@@ -175,6 +180,8 @@ export {
|
|||||||
getThinkingTokenBudget,
|
getThinkingTokenBudget,
|
||||||
// Event hook constants
|
// Event hook constants
|
||||||
EVENT_HOOK_TRIGGER_LABELS,
|
EVENT_HOOK_TRIGGER_LABELS,
|
||||||
|
// Claude API profile constants
|
||||||
|
CLAUDE_API_PROFILE_TEMPLATES,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
|
|
||||||
// Model display constants
|
// Model display constants
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Shared types for AI model providers
|
* Shared types for AI model providers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ThinkingLevel } from './settings.js';
|
import type { ThinkingLevel, ClaudeApiProfile, Credentials } from './settings.js';
|
||||||
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
|
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,6 +209,17 @@ export interface ExecuteOptions {
|
|||||||
type: 'json_schema';
|
type: 'json_schema';
|
||||||
schema: Record<string, unknown>;
|
schema: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Active Claude API profile for alternative endpoint configuration.
|
||||||
|
* When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API.
|
||||||
|
* When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth).
|
||||||
|
*/
|
||||||
|
claudeApiProfile?: ClaudeApiProfile;
|
||||||
|
/**
|
||||||
|
* Credentials for resolving 'credentials' apiKeySource in Claude API profiles.
|
||||||
|
* When a profile has apiKeySource='credentials', the Anthropic key from this object is used.
|
||||||
|
*/
|
||||||
|
credentials?: Credentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -101,6 +101,137 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
|
|||||||
/** ModelProvider - AI model provider for credentials and API key management */
|
/** ModelProvider - AI model provider for credentials and API key management */
|
||||||
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
|
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Claude API Profiles - Configuration for Claude-compatible API endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiKeySource - Strategy for sourcing API keys
|
||||||
|
*
|
||||||
|
* - 'inline': API key stored directly in the profile (legacy/default behavior)
|
||||||
|
* - 'env': Use ANTHROPIC_API_KEY environment variable
|
||||||
|
* - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json)
|
||||||
|
*/
|
||||||
|
export type ApiKeySource = 'inline' | 'env' | 'credentials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClaudeApiProfile - Configuration for a Claude-compatible API endpoint
|
||||||
|
*
|
||||||
|
* Allows using alternative providers like z.AI GLM, AWS Bedrock, etc.
|
||||||
|
*/
|
||||||
|
export interface ClaudeApiProfile {
|
||||||
|
/** Unique identifier (uuid) */
|
||||||
|
id: string;
|
||||||
|
/** Display name (e.g., "z.AI GLM", "AWS Bedrock") */
|
||||||
|
name: string;
|
||||||
|
/** ANTHROPIC_BASE_URL - custom API endpoint */
|
||||||
|
baseUrl: string;
|
||||||
|
/**
|
||||||
|
* API key sourcing strategy (default: 'inline' for backwards compatibility)
|
||||||
|
* - 'inline': Use apiKey field value
|
||||||
|
* - 'env': Use ANTHROPIC_API_KEY environment variable
|
||||||
|
* - 'credentials': Use the Anthropic key from credentials.json
|
||||||
|
*/
|
||||||
|
apiKeySource?: ApiKeySource;
|
||||||
|
/** API key value (only required when apiKeySource = 'inline' or undefined) */
|
||||||
|
apiKey?: string;
|
||||||
|
/** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */
|
||||||
|
useAuthToken?: boolean;
|
||||||
|
/** API_TIMEOUT_MS override in milliseconds */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Optional model name mappings */
|
||||||
|
modelMappings?: {
|
||||||
|
/** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */
|
||||||
|
haiku?: string;
|
||||||
|
/** Maps to ANTHROPIC_DEFAULT_SONNET_MODEL */
|
||||||
|
sonnet?: string;
|
||||||
|
/** Maps to ANTHROPIC_DEFAULT_OPUS_MODEL */
|
||||||
|
opus?: string;
|
||||||
|
};
|
||||||
|
/** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */
|
||||||
|
disableNonessentialTraffic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Known provider templates for quick setup */
|
||||||
|
export interface ClaudeApiProfileTemplate {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
/** Default API key source for this template (user chooses when creating) */
|
||||||
|
defaultApiKeySource?: ApiKeySource;
|
||||||
|
useAuthToken: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
modelMappings?: ClaudeApiProfile['modelMappings'];
|
||||||
|
disableNonessentialTraffic?: boolean;
|
||||||
|
description: string;
|
||||||
|
apiKeyUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Predefined templates for known Claude-compatible providers */
|
||||||
|
export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'Direct Anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
defaultApiKeySource: 'credentials',
|
||||||
|
useAuthToken: false,
|
||||||
|
description: 'Standard Anthropic API with your API key',
|
||||||
|
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OpenRouter',
|
||||||
|
baseUrl: 'https://openrouter.ai/api',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
description: 'Access Claude and 300+ models via OpenRouter',
|
||||||
|
apiKeyUrl: 'https://openrouter.ai/keys',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'z.AI GLM',
|
||||||
|
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'GLM-4.5-Air',
|
||||||
|
sonnet: 'GLM-4.7',
|
||||||
|
opus: 'GLM-4.7',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: '3× usage at fraction of cost via GLM Coding Plan',
|
||||||
|
apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MiniMax',
|
||||||
|
baseUrl: 'https://api.minimax.io/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'MiniMax-M2.1',
|
||||||
|
sonnet: 'MiniMax-M2.1',
|
||||||
|
opus: 'MiniMax-M2.1',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: 'MiniMax M2.1 coding model with extended context',
|
||||||
|
apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MiniMax (China)',
|
||||||
|
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||||
|
defaultApiKeySource: 'inline',
|
||||||
|
useAuthToken: true,
|
||||||
|
timeoutMs: 3000000,
|
||||||
|
modelMappings: {
|
||||||
|
haiku: 'MiniMax-M2.1',
|
||||||
|
sonnet: 'MiniMax-M2.1',
|
||||||
|
opus: 'MiniMax-M2.1',
|
||||||
|
},
|
||||||
|
disableNonessentialTraffic: true,
|
||||||
|
description: 'MiniMax M2.1 for users in China',
|
||||||
|
apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
|
||||||
|
},
|
||||||
|
// Future: Add AWS Bedrock, Google Vertex, etc.
|
||||||
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Event Hooks - Custom actions triggered by system events
|
// Event Hooks - Custom actions triggered by system events
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -658,6 +789,19 @@ export interface GlobalSettings {
|
|||||||
* @see EventHook for configuration details
|
* @see EventHook for configuration details
|
||||||
*/
|
*/
|
||||||
eventHooks?: EventHook[];
|
eventHooks?: EventHook[];
|
||||||
|
|
||||||
|
// Claude API Profiles Configuration
|
||||||
|
/**
|
||||||
|
* Claude-compatible API endpoint profiles
|
||||||
|
* Allows using alternative providers like z.AI GLM, AWS Bedrock, etc.
|
||||||
|
*/
|
||||||
|
claudeApiProfiles?: ClaudeApiProfile[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active profile ID (null/undefined = use direct Anthropic API)
|
||||||
|
* When set, the corresponding profile's settings will be used for Claude API calls
|
||||||
|
*/
|
||||||
|
activeClaudeApiProfileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -794,6 +938,15 @@ export interface ProjectSettings {
|
|||||||
automodeEnabled?: boolean;
|
automodeEnabled?: boolean;
|
||||||
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
|
/** Maximum concurrent agents for this project (overrides global maxConcurrency) */
|
||||||
maxConcurrentAgents?: number;
|
maxConcurrentAgents?: number;
|
||||||
|
|
||||||
|
// Claude API Profile Override (per-project)
|
||||||
|
/**
|
||||||
|
* Override the active Claude API profile for this project.
|
||||||
|
* - undefined: Use global setting (activeClaudeApiProfileId)
|
||||||
|
* - null: Explicitly use Direct Anthropic API (no profile)
|
||||||
|
* - string: Use specific profile by ID
|
||||||
|
*/
|
||||||
|
activeClaudeApiProfileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -827,12 +980,15 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Current version of the global settings schema */
|
/** Current version of the global settings schema */
|
||||||
export const SETTINGS_VERSION = 4;
|
export const SETTINGS_VERSION = 5;
|
||||||
/** Current version of the credentials schema */
|
/** Current version of the credentials schema */
|
||||||
export const CREDENTIALS_VERSION = 1;
|
export const CREDENTIALS_VERSION = 1;
|
||||||
/** Current version of the project settings schema */
|
/** Current version of the project settings schema */
|
||||||
export const PROJECT_SETTINGS_VERSION = 1;
|
export const PROJECT_SETTINGS_VERSION = 1;
|
||||||
|
|
||||||
|
/** Default maximum concurrent agents for auto mode */
|
||||||
|
export const DEFAULT_MAX_CONCURRENCY = 1;
|
||||||
|
|
||||||
/** Default keyboard shortcut bindings */
|
/** Default keyboard shortcut bindings */
|
||||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||||
board: 'K',
|
board: 'K',
|
||||||
@@ -866,7 +1022,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||||
defaultSkipTests: true,
|
defaultSkipTests: true,
|
||||||
enableDependencyBlocking: true,
|
enableDependencyBlocking: true,
|
||||||
skipVerificationInAutoMode: false,
|
skipVerificationInAutoMode: false,
|
||||||
@@ -913,6 +1069,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
skillsSources: ['user', 'project'],
|
skillsSources: ['user', 'project'],
|
||||||
enableSubagents: true,
|
enableSubagents: true,
|
||||||
subagentsSources: ['user', 'project'],
|
subagentsSources: ['user', 'project'],
|
||||||
|
claudeApiProfiles: [],
|
||||||
|
activeClaudeApiProfileId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default credentials (empty strings - user must provide API keys) */
|
/** Default credentials (empty strings - user must provide API keys) */
|
||||||
|
|||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -6218,6 +6218,7 @@
|
|||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -6227,7 +6228,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -8438,6 +8439,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-color": {
|
"node_modules/d3-color": {
|
||||||
@@ -11331,7 +11333,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11353,7 +11354,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11375,7 +11375,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11397,7 +11396,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11419,7 +11417,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11441,7 +11438,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11463,7 +11459,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11485,7 +11480,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11507,7 +11501,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11529,7 +11522,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
@@ -11551,7 +11543,6 @@
|
|||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs",
|
"postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs",
|
||||||
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
|
"fix:lockfile": "node scripts/fix-lockfile-urls.mjs",
|
||||||
"dev": "./start-automaker.sh",
|
"dev": "node start-automaker.mjs",
|
||||||
"start": "./start-automaker.sh --production",
|
"start": "node start-automaker.mjs --production",
|
||||||
"_dev:web": "npm run dev:web --workspace=apps/ui",
|
"_dev:web": "npm run dev:web --workspace=apps/ui",
|
||||||
"_dev:electron": "npm run dev:electron --workspace=apps/ui",
|
"_dev:electron": "npm run dev:electron --workspace=apps/ui",
|
||||||
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",
|
||||||
|
|||||||
201
start-automaker.mjs
Normal file
201
start-automaker.mjs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Cross-platform launcher for Automaker
|
||||||
|
* Works on Windows (CMD, PowerShell, Git Bash) and Unix (macOS, Linux)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { platform } from 'os';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const isWindows = platform() === 'win32';
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the bash variant by checking $OSTYPE
|
||||||
|
* This is more reliable than path-based detection since bash.exe in PATH
|
||||||
|
* could be Git Bash, WSL, or something else
|
||||||
|
* @param {string} bashPath - Path to bash executable
|
||||||
|
* @returns {'WSL' | 'MSYS' | 'CYGWIN' | 'UNKNOWN'} The detected bash variant
|
||||||
|
*/
|
||||||
|
function detectBashVariant(bashPath) {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(bashPath, ['-c', 'echo $OSTYPE'], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
if (result.status === 0) {
|
||||||
|
const ostype = result.stdout.toString().trim();
|
||||||
|
// WSL reports 'linux-gnu' or similar Linux identifier
|
||||||
|
if (ostype === 'linux-gnu' || ostype.startsWith('linux')) return 'WSL';
|
||||||
|
// MSYS2/Git Bash reports 'msys' or 'mingw*'
|
||||||
|
if (ostype.startsWith('msys') || ostype.startsWith('mingw')) return 'MSYS';
|
||||||
|
// Cygwin reports 'cygwin'
|
||||||
|
if (ostype.startsWith('cygwin')) return 'CYGWIN';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to path-based detection
|
||||||
|
}
|
||||||
|
// Fallback to path-based detection if $OSTYPE check fails
|
||||||
|
const lower = bashPath.toLowerCase();
|
||||||
|
if (lower.includes('cygwin')) return 'CYGWIN';
|
||||||
|
if (lower.includes('system32')) return 'WSL';
|
||||||
|
// Default to MSYS (Git Bash) as it's the most common
|
||||||
|
return 'MSYS';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Windows path to Unix-style for the detected bash variant
|
||||||
|
* @param {string} windowsPath - Windows-style path (e.g., C:\path\to\file)
|
||||||
|
* @param {string} bashCmd - Path to bash executable (used to detect variant)
|
||||||
|
* @returns {string} Unix-style path appropriate for the bash variant
|
||||||
|
*/
|
||||||
|
function convertPathForBash(windowsPath, bashCmd) {
|
||||||
|
// Input validation
|
||||||
|
if (!windowsPath || typeof windowsPath !== 'string') {
|
||||||
|
throw new Error('convertPathForBash: invalid windowsPath');
|
||||||
|
}
|
||||||
|
if (!bashCmd || typeof bashCmd !== 'string') {
|
||||||
|
throw new Error('convertPathForBash: invalid bashCmd');
|
||||||
|
}
|
||||||
|
|
||||||
|
let unixPath = windowsPath.replace(/\\/g, '/');
|
||||||
|
if (/^[A-Za-z]:/.test(unixPath)) {
|
||||||
|
const drive = unixPath[0].toLowerCase();
|
||||||
|
const pathPart = unixPath.slice(2);
|
||||||
|
|
||||||
|
// Detect bash variant via $OSTYPE (more reliable than path-based)
|
||||||
|
const variant = detectBashVariant(bashCmd);
|
||||||
|
switch (variant) {
|
||||||
|
case 'CYGWIN':
|
||||||
|
// Cygwin expects /cygdrive/c/path format
|
||||||
|
return `/cygdrive/${drive}${pathPart}`;
|
||||||
|
case 'WSL':
|
||||||
|
// WSL expects /mnt/c/path format
|
||||||
|
return `/mnt/${drive}${pathPart}`;
|
||||||
|
case 'MSYS':
|
||||||
|
default:
|
||||||
|
// MSYS2/Git Bash expects /c/path format
|
||||||
|
return `/${drive}${pathPart}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unixPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find bash executable on Windows
|
||||||
|
*/
|
||||||
|
function findBashOnWindows() {
|
||||||
|
const possiblePaths = [
|
||||||
|
// Git Bash (most common)
|
||||||
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||||
|
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
||||||
|
// MSYS2
|
||||||
|
'C:\\msys64\\usr\\bin\\bash.exe',
|
||||||
|
'C:\\msys32\\usr\\bin\\bash.exe',
|
||||||
|
// Cygwin
|
||||||
|
'C:\\cygwin64\\bin\\bash.exe',
|
||||||
|
'C:\\cygwin\\bin\\bash.exe',
|
||||||
|
// WSL bash (available in PATH on Windows 10+)
|
||||||
|
'bash.exe',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const bashPath of possiblePaths) {
|
||||||
|
if (bashPath === 'bash.exe') {
|
||||||
|
// Check if bash is in PATH
|
||||||
|
try {
|
||||||
|
const result = spawnSync('where', ['bash.exe'], { stdio: 'pipe' });
|
||||||
|
if (result?.status === 0) {
|
||||||
|
return 'bash.exe';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// where command failed, continue checking other paths
|
||||||
|
}
|
||||||
|
} else if (existsSync(bashPath)) {
|
||||||
|
return bashPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the bash script
|
||||||
|
*/
|
||||||
|
function runBashScript() {
|
||||||
|
const scriptPath = join(__dirname, 'start-automaker.sh');
|
||||||
|
|
||||||
|
if (!existsSync(scriptPath)) {
|
||||||
|
console.error('Error: start-automaker.sh not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bashCmd;
|
||||||
|
let bashArgs;
|
||||||
|
|
||||||
|
if (isWindows) {
|
||||||
|
bashCmd = findBashOnWindows();
|
||||||
|
|
||||||
|
if (!bashCmd) {
|
||||||
|
console.error('Error: Could not find bash on Windows.');
|
||||||
|
console.error('Please install Git for Windows from https://git-scm.com/download/win');
|
||||||
|
console.error('');
|
||||||
|
console.error('Alternatively, you can run these commands directly:');
|
||||||
|
console.error(' npm run dev:web - Web browser mode');
|
||||||
|
console.error(' npm run dev:electron - Desktop app mode');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Windows path to appropriate Unix-style for the detected bash variant
|
||||||
|
const unixPath = convertPathForBash(scriptPath, bashCmd);
|
||||||
|
bashArgs = [unixPath, ...args];
|
||||||
|
} else {
|
||||||
|
bashCmd = '/bin/bash';
|
||||||
|
bashArgs = [scriptPath, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(bashCmd, bashArgs, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Ensure proper terminal handling
|
||||||
|
TERM: process.env.TERM || 'xterm-256color',
|
||||||
|
},
|
||||||
|
// shell: false ensures signals are forwarded directly to the child process
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.error(`Error: Could not find bash at "${bashCmd}"`);
|
||||||
|
console.error('Please ensure Git Bash or another bash shell is installed.');
|
||||||
|
} else {
|
||||||
|
console.error('Error launching Automaker:', err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
// Process was killed by a signal - exit with 1 to indicate abnormal termination
|
||||||
|
// (Unix convention is 128 + signal number, but we use 1 for simplicity)
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward signals to child process (guard against race conditions)
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
if (!child.killed) child.kill('SIGINT');
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
if (!child.killed) child.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runBashScript();
|
||||||
@@ -34,9 +34,41 @@ fi
|
|||||||
# Port configuration
|
# Port configuration
|
||||||
DEFAULT_WEB_PORT=3007
|
DEFAULT_WEB_PORT=3007
|
||||||
DEFAULT_SERVER_PORT=3008
|
DEFAULT_SERVER_PORT=3008
|
||||||
|
PORT_SEARCH_MAX_ATTEMPTS=100
|
||||||
WEB_PORT=$DEFAULT_WEB_PORT
|
WEB_PORT=$DEFAULT_WEB_PORT
|
||||||
SERVER_PORT=$DEFAULT_SERVER_PORT
|
SERVER_PORT=$DEFAULT_SERVER_PORT
|
||||||
|
|
||||||
|
# Port validation function
|
||||||
|
# Returns 0 if valid, 1 if invalid (with error message printed)
|
||||||
|
validate_port() {
|
||||||
|
local port="$1"
|
||||||
|
local port_name="${2:-port}"
|
||||||
|
|
||||||
|
# Check if port is a number
|
||||||
|
if ! [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "${C_RED}Error:${RESET} $port_name must be a number, got '$port'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if port is in valid range (1-65535)
|
||||||
|
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||||||
|
echo "${C_RED}Error:${RESET} $port_name must be between 1-65535, got '$port'"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if port is in privileged range (warning only)
|
||||||
|
if [ "$port" -lt 1024 ]; then
|
||||||
|
echo "${C_YELLOW}Warning:${RESET} $port_name $port is in privileged range (requires root/admin)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hostname configuration
|
||||||
|
# Use VITE_HOSTNAME if explicitly set, otherwise default to localhost
|
||||||
|
# Note: Don't use $HOSTNAME as it's a bash built-in containing the machine's hostname
|
||||||
|
APP_HOST="${VITE_HOSTNAME:-localhost}"
|
||||||
|
|
||||||
# Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version)
|
# Extract VERSION from apps/ui/package.json (the actual app version, not monorepo version)
|
||||||
if command -v node &> /dev/null; then
|
if command -v node &> /dev/null; then
|
||||||
VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")"
|
VERSION="v$(node -p "require('$SCRIPT_DIR/apps/ui/package.json').version" 2>/dev/null || echo "0.11.0")"
|
||||||
@@ -422,6 +454,25 @@ is_port_in_use() {
|
|||||||
[ -n "$pids" ] && [ "$pids" != " " ]
|
[ -n "$pids" ] && [ "$pids" != " " ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Find the next available port starting from a given port
|
||||||
|
# Returns the port on stdout if found, nothing if all ports in range are busy
|
||||||
|
# Exit code: 0 if found, 1 if no available port in range
|
||||||
|
find_next_available_port() {
|
||||||
|
local start_port=$1
|
||||||
|
local port=$start_port
|
||||||
|
|
||||||
|
for ((i=0; i<PORT_SEARCH_MAX_ATTEMPTS; i++)); do
|
||||||
|
if ! is_port_in_use "$port"; then
|
||||||
|
echo "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
port=$((port + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# No free port found in the scan range
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
kill_port() {
|
kill_port() {
|
||||||
local port=$1
|
local port=$1
|
||||||
local pids
|
local pids
|
||||||
@@ -460,9 +511,7 @@ kill_port() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_ports() {
|
check_ports() {
|
||||||
show_cursor
|
# Auto-discover available ports (no user interaction required)
|
||||||
stty echo icanon 2>/dev/null || true
|
|
||||||
|
|
||||||
local web_in_use=false
|
local web_in_use=false
|
||||||
local server_in_use=false
|
local server_in_use=false
|
||||||
|
|
||||||
@@ -475,59 +524,46 @@ check_ports() {
|
|||||||
|
|
||||||
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
|
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
local max_port
|
||||||
if [ "$web_in_use" = true ]; then
|
if [ "$web_in_use" = true ]; then
|
||||||
local pids
|
local pids
|
||||||
pids=$(get_pids_on_port "$DEFAULT_WEB_PORT")
|
# Get PIDs and convert newlines to spaces for display
|
||||||
echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_WEB_PORT is in use by process(es): $pids"
|
pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs)
|
||||||
|
echo "${C_YELLOW}Port $DEFAULT_WEB_PORT in use (PID: $pids), finding alternative...${RESET}"
|
||||||
|
max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then
|
||||||
|
echo "${C_RED}Error: No free web port in range ${DEFAULT_WEB_PORT}-${max_port}${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$server_in_use" = true ]; then
|
if [ "$server_in_use" = true ]; then
|
||||||
local pids
|
local pids
|
||||||
pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT")
|
# Get PIDs and convert newlines to spaces for display
|
||||||
echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_SERVER_PORT is in use by process(es): $pids"
|
pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs)
|
||||||
|
echo "${C_YELLOW}Port $DEFAULT_SERVER_PORT in use (PID: $pids), finding alternative...${RESET}"
|
||||||
|
max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then
|
||||||
|
echo "${C_RED}Error: No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure web and server ports don't conflict with each other
|
||||||
|
if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then
|
||||||
|
local conflict_start=$((SERVER_PORT + 1))
|
||||||
|
max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then
|
||||||
|
echo "${C_RED}Error: No free server port in range ${conflict_start}-${max_port}${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
read -r -p "What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: " choice
|
|
||||||
case "$choice" in
|
|
||||||
[kK]|[kK][iI][lL][lL])
|
|
||||||
if [ "$web_in_use" = true ]; then
|
|
||||||
kill_port "$DEFAULT_WEB_PORT"
|
|
||||||
else
|
|
||||||
echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available"
|
|
||||||
fi
|
|
||||||
if [ "$server_in_use" = true ]; then
|
|
||||||
kill_port "$DEFAULT_SERVER_PORT"
|
|
||||||
else
|
|
||||||
echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available"
|
|
||||||
fi
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
[uU]|[uU][sS][eE])
|
|
||||||
read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web
|
|
||||||
WEB_PORT=${input_web:-$DEFAULT_WEB_PORT}
|
|
||||||
read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server
|
|
||||||
SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT}
|
|
||||||
echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
[cC]|[cC][aA][nN][cC][eE][lL])
|
|
||||||
echo "${C_MUTE}Cancelled.${RESET}"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "${C_RED}Invalid choice. Please enter k, u, or c.${RESET}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "${C_GREEN}✓ Auto-selected available ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}"
|
||||||
else
|
else
|
||||||
echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available"
|
echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available"
|
||||||
echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available"
|
echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
hide_cursor
|
|
||||||
stty -echo -icanon 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_terminal_size() {
|
validate_terminal_size() {
|
||||||
@@ -747,37 +783,70 @@ resolve_port_conflicts() {
|
|||||||
|
|
||||||
if is_port_in_use "$DEFAULT_WEB_PORT"; then
|
if is_port_in_use "$DEFAULT_WEB_PORT"; then
|
||||||
web_in_use=true
|
web_in_use=true
|
||||||
web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT")
|
# Get PIDs and convert newlines to spaces for display
|
||||||
|
web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs)
|
||||||
fi
|
fi
|
||||||
if is_port_in_use "$DEFAULT_SERVER_PORT"; then
|
if is_port_in_use "$DEFAULT_SERVER_PORT"; then
|
||||||
server_in_use=true
|
server_in_use=true
|
||||||
server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT")
|
# Get PIDs and convert newlines to spaces for display
|
||||||
|
server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
|
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$web_in_use" = true ]; then
|
if [ "$web_in_use" = true ]; then
|
||||||
center_print "⚠ Port $DEFAULT_WEB_PORT is in use by process(es): $web_pids" "$C_YELLOW"
|
center_print "Port $DEFAULT_WEB_PORT in use (PID: $web_pids)" "$C_YELLOW"
|
||||||
fi
|
fi
|
||||||
if [ "$server_in_use" = true ]; then
|
if [ "$server_in_use" = true ]; then
|
||||||
center_print "⚠ Port $DEFAULT_SERVER_PORT is in use by process(es): $server_pids" "$C_YELLOW"
|
center_print "Port $DEFAULT_SERVER_PORT in use (PID: $server_pids)" "$C_YELLOW"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Show options
|
# Show options
|
||||||
center_print "What would you like to do?" "$C_WHITE"
|
center_print "What would you like to do?" "$C_WHITE"
|
||||||
echo ""
|
echo ""
|
||||||
center_print "[K] Kill processes and continue" "$C_GREEN"
|
center_print "[Enter] Auto-select available ports (Recommended)" "$C_GREEN"
|
||||||
center_print "[U] Use different ports" "$C_MUTE"
|
center_print "[K] Kill processes and use default ports" "$C_MUTE"
|
||||||
center_print "[C] Cancel" "$C_RED"
|
center_print "[C] Choose custom ports" "$C_MUTE"
|
||||||
|
center_print "[X] Cancel" "$C_RED"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
local choice_pad=$(( (TERM_COLS - 20) / 2 ))
|
local choice_pad=$(( (TERM_COLS - 20) / 2 ))
|
||||||
printf "%${choice_pad}s" ""
|
printf "%${choice_pad}s" ""
|
||||||
read -r -p "Choice: " choice
|
read -r -p "Choice [Enter]: " choice
|
||||||
|
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
|
""|[aA]|[aA][uU][tT][oO])
|
||||||
|
# Auto-select: find next available ports
|
||||||
|
echo ""
|
||||||
|
local max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if [ "$web_in_use" = true ]; then
|
||||||
|
if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then
|
||||||
|
center_print "No free web port in range ${DEFAULT_WEB_PORT}-${max_port}" "$C_RED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if [ "$server_in_use" = true ]; then
|
||||||
|
if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then
|
||||||
|
center_print "No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}" "$C_RED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Ensure web and server ports don't conflict with each other
|
||||||
|
if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then
|
||||||
|
local conflict_start=$((SERVER_PORT + 1))
|
||||||
|
max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1))
|
||||||
|
if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then
|
||||||
|
center_print "No free server port in range ${conflict_start}-${max_port}" "$C_RED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
center_print "✓ Auto-selected available ports:" "$C_GREEN"
|
||||||
|
center_print " Web: $WEB_PORT | Server: $SERVER_PORT" "$C_PRI"
|
||||||
|
break
|
||||||
|
;;
|
||||||
[kK]|[kK][iI][lL][lL])
|
[kK]|[kK][iI][lL][lL])
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$web_in_use" = true ]; then
|
if [ "$web_in_use" = true ]; then
|
||||||
@@ -792,26 +861,39 @@ resolve_port_conflicts() {
|
|||||||
fi
|
fi
|
||||||
break
|
break
|
||||||
;;
|
;;
|
||||||
[uU]|[uU][sS][eE])
|
[cC]|[cC][hH][oO][oO][sS][eE])
|
||||||
echo ""
|
echo ""
|
||||||
local input_pad=$(( (TERM_COLS - 40) / 2 ))
|
local input_pad=$(( (TERM_COLS - 40) / 2 ))
|
||||||
|
# Collect both ports first
|
||||||
printf "%${input_pad}s" ""
|
printf "%${input_pad}s" ""
|
||||||
read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web
|
read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web
|
||||||
WEB_PORT=${input_web:-$DEFAULT_WEB_PORT}
|
input_web=${input_web:-$DEFAULT_WEB_PORT}
|
||||||
printf "%${input_pad}s" ""
|
printf "%${input_pad}s" ""
|
||||||
read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server
|
read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server
|
||||||
SERVER_PORT=${input_server:-$DEFAULT_SERVER_PORT}
|
input_server=${input_server:-$DEFAULT_SERVER_PORT}
|
||||||
|
|
||||||
|
# Validate both before assigning either
|
||||||
|
if ! validate_port "$input_web" "Web port"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! validate_port "$input_server" "Server port"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Assign atomically after both validated
|
||||||
|
WEB_PORT=$input_web
|
||||||
|
SERVER_PORT=$input_server
|
||||||
center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN"
|
center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN"
|
||||||
break
|
break
|
||||||
;;
|
;;
|
||||||
[cC]|[cC][aA][nN][cC][eE][lL])
|
[xX]|[xX][cC][aA][nN][cC][eE][lL])
|
||||||
echo ""
|
echo ""
|
||||||
center_print "Cancelled." "$C_MUTE"
|
center_print "Cancelled." "$C_MUTE"
|
||||||
echo ""
|
echo ""
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
center_print "Invalid choice. Please enter K, U, or C." "$C_RED"
|
center_print "Invalid choice. Press Enter for auto-select, or K/C/X." "$C_RED"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -850,7 +932,7 @@ launch_sequence() {
|
|||||||
|
|
||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
web)
|
web)
|
||||||
local url="http://localhost:$WEB_PORT"
|
local url="http://${APP_HOST}:$WEB_PORT"
|
||||||
local upad=$(( (TERM_COLS - ${#url} - 10) / 2 ))
|
local upad=$(( (TERM_COLS - ${#url} - 10) / 2 ))
|
||||||
echo ""
|
echo ""
|
||||||
printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url"
|
printf "%${upad}s${DIM}Opening ${C_SEC}%s${RESET}\n" "" "$url"
|
||||||
@@ -1073,10 +1155,15 @@ fi
|
|||||||
case $MODE in
|
case $MODE in
|
||||||
web)
|
web)
|
||||||
export TEST_PORT="$WEB_PORT"
|
export TEST_PORT="$WEB_PORT"
|
||||||
export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT"
|
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
|
||||||
export PORT="$SERVER_PORT"
|
export PORT="$SERVER_PORT"
|
||||||
export DATA_DIR="$SCRIPT_DIR/data"
|
export DATA_DIR="$SCRIPT_DIR/data"
|
||||||
export CORS_ORIGIN="http://localhost:$WEB_PORT,http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT"
|
# Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different
|
||||||
|
CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT"
|
||||||
|
if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then
|
||||||
|
CORS_ORIGINS="${CORS_ORIGINS},http://${APP_HOST}:$WEB_PORT"
|
||||||
|
fi
|
||||||
|
export CORS_ORIGIN="$CORS_ORIGINS"
|
||||||
export VITE_APP_MODE="1"
|
export VITE_APP_MODE="1"
|
||||||
|
|
||||||
if [ "$PRODUCTION_MODE" = true ]; then
|
if [ "$PRODUCTION_MODE" = true ]; then
|
||||||
@@ -1092,7 +1179,7 @@ case $MODE in
|
|||||||
max_retries=30
|
max_retries=30
|
||||||
server_ready=false
|
server_ready=false
|
||||||
for ((i=0; i<max_retries; i++)); do
|
for ((i=0; i<max_retries; i++)); do
|
||||||
if curl -s "http://$HOSTNAME:$SERVER_PORT/api/health" > /dev/null 2>&1; then
|
if curl -s "http://localhost:$SERVER_PORT/api/health" > /dev/null 2>&1; then
|
||||||
server_ready=true
|
server_ready=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1148,7 +1235,7 @@ case $MODE in
|
|||||||
center_print "✓ Server is ready!" "$C_GREEN"
|
center_print "✓ Server is ready!" "$C_GREEN"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
center_print "The application will be available at: http://localhost:$WEB_PORT" "$C_GREEN"
|
center_print "The application will be available at: http://${APP_HOST}:$WEB_PORT" "$C_GREEN"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start web app with Vite dev server (HMR enabled)
|
# Start web app with Vite dev server (HMR enabled)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user