mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
50 Commits
v0.12.0
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88c940a36 | ||
|
|
10b49bd3b4 | ||
|
|
63b8eb0991 | ||
|
|
a52c0461e5 | ||
|
|
e73c92b031 | ||
|
|
09151aa3c8 | ||
|
|
d6300f33ca | ||
|
|
4b0d1399b1 | ||
|
|
55a34a9f1f | ||
|
|
c4652190eb | ||
|
|
af95dae73a | ||
|
|
1c1d9d30a7 | ||
|
|
3faebfa3fe | ||
|
|
d0eaf0e51d | ||
|
|
da80729f56 | ||
|
|
749fb3a5c1 | ||
|
|
dd26de9f55 | ||
|
|
b6cb926cbe | ||
|
|
eb30ef71f9 | ||
|
|
75fe579e93 | ||
|
|
8ab9dc5a11 | ||
|
|
96202d4bc2 | ||
|
|
f68aee6a19 | ||
|
|
7795d81183 | ||
|
|
0c053dab48 | ||
|
|
1ede7e7e6a | ||
|
|
980006d40e | ||
|
|
ef2dcbacd4 | ||
|
|
505a2b1e0b | ||
|
|
2e57553639 | ||
|
|
f37812247d | ||
|
|
53298106e9 | ||
|
|
484d4c65d5 | ||
|
|
327aef89a2 | ||
|
|
44e665f1bf | ||
|
|
5b1e0105f4 | ||
|
|
832d10e133 | ||
|
|
7b7ac72c14 | ||
|
|
9137f0e75f | ||
|
|
b66efae5b7 | ||
|
|
2a8706e714 | ||
|
|
174c02cb79 | ||
|
|
a7f7898ee4 | ||
|
|
fdad82bf88 | ||
|
|
b0b49764b9 | ||
|
|
e10cb83adc | ||
|
|
b8875f71a5 | ||
|
|
4186b80a82 | ||
|
|
7eae0215f2 | ||
|
|
4cd84a4734 |
@@ -172,4 +172,5 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
|
||||
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)
|
||||
|
||||
@@ -389,6 +389,7 @@ npm run lint
|
||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const HOSTNAME = process.env.HOSTNAME || 'localhost';
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
|
||||
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
|
||||
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
|
||||
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
|
||||
// Runtime-configurable request logging flag (can be changed via settings)
|
||||
@@ -110,24 +113,37 @@ export function isRequestLoggingEnabled(): boolean {
|
||||
return requestLoggingEnabled;
|
||||
}
|
||||
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
logger.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ WARNING: No Claude authentication configured ║
|
||||
║ ║
|
||||
║ The Claude Agent SDK requires authentication to function. ║
|
||||
║ ║
|
||||
║ Set your Anthropic API key: ║
|
||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||
║ ║
|
||||
║ Or use the setup wizard in Settings to configure authentication. ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${wHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${w1}║
|
||||
║ ║
|
||||
║ ${w2}║
|
||||
║ ${w3}║
|
||||
║ ║
|
||||
║ ${w4}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
}
|
||||
|
||||
// Initialize security
|
||||
@@ -175,14 +191,25 @@ app.use(
|
||||
return;
|
||||
}
|
||||
|
||||
// For local development, allow localhost origins
|
||||
if (
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:') ||
|
||||
origin.startsWith('http://[::1]:')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
// For local development, allow all localhost/loopback origins (any port)
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
const hostname = url.hostname;
|
||||
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '0.0.0.0' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.')
|
||||
) {
|
||||
callback(null, origin);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore URL parsing errors
|
||||
}
|
||||
|
||||
// Reject other origins by default for security
|
||||
@@ -226,6 +253,23 @@ eventHookService.initialize(events, settingsService, eventHistoryService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
// Migrate settings from legacy Electron userData location if needed
|
||||
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
|
||||
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
|
||||
// to the new shared ./data directory
|
||||
try {
|
||||
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
|
||||
if (migrationResult.migrated) {
|
||||
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
|
||||
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
|
||||
}
|
||||
if (migrationResult.errors.length > 0) {
|
||||
logger.warn('Migration errors:', migrationResult.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to check for legacy settings migration:', err);
|
||||
}
|
||||
|
||||
// Apply logging settings from saved settings
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
@@ -618,40 +662,74 @@ const startServer = (port: number, host: string) => {
|
||||
? 'enabled (password protected)'
|
||||
: 'enabled'
|
||||
: 'disabled';
|
||||
const portStr = port.toString().padEnd(4);
|
||||
|
||||
// Build URLs for display
|
||||
const listenAddr = `${host}:${port}`;
|
||||
const httpUrl = `http://${HOSTNAME}:${port}`;
|
||||
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
|
||||
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
|
||||
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
|
||||
|
||||
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ Listening: ${host}:${port}${' '.repeat(Math.max(0, 34 - host.length - port.toString().length))}║
|
||||
║ HTTP API: http://${HOSTNAME}:${portStr} ║
|
||||
║ WebSocket: ws://${HOSTNAME}:${portStr}/api/events ║
|
||||
║ Terminal: ws://${HOSTNAME}:${portStr}/api/terminal/ws ║
|
||||
║ Health: http://${HOSTNAME}:${portStr}/api/health ║
|
||||
║ Terminal: ${terminalStatus.padEnd(37)}║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${sHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${s1}║
|
||||
║ ${s2}║
|
||||
║ ${s3}║
|
||||
║ ${s4}║
|
||||
║ ${s5}║
|
||||
║ ${s6}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
const portStr = port.toString();
|
||||
const nextPortStr = (port + 1).toString();
|
||||
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
|
||||
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
|
||||
|
||||
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.error(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ ❌ ERROR: Port ${port} is already in use ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ Another process is using this port. ║
|
||||
║ ║
|
||||
║ To fix this, try one of: ║
|
||||
║ ║
|
||||
║ 1. Kill the process using the port: ║
|
||||
║ lsof -ti:${port} | xargs kill -9 ║
|
||||
║ ║
|
||||
║ 2. Use a different port: ║
|
||||
║ PORT=${port + 1} npm run dev:server ║
|
||||
║ ║
|
||||
║ 3. Use the init.sh script which handles this: ║
|
||||
║ ./init.sh ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${eHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${e1}║
|
||||
║ ║
|
||||
║ ${e2}║
|
||||
║ ║
|
||||
║ ${e3}║
|
||||
║ ${e4}║
|
||||
║ ║
|
||||
║ ${e5}║
|
||||
║ ${e6}║
|
||||
║ ║
|
||||
║ ${e7}║
|
||||
║ ${e8}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
|
||||
@@ -130,21 +130,47 @@ function ensureApiKey(): string {
|
||||
// API key - always generated/loaded on startup for CSRF protection
|
||||
const API_KEY = ensureApiKey();
|
||||
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
|
||||
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
||||
|
||||
// Build box lines with exact padding
|
||||
const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH);
|
||||
const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH);
|
||||
|
||||
logger.info(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ 🔐 API Key for Web Mode Authentication ║
|
||||
╠═══════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ When accessing via browser, you'll be prompted to enter this key: ║
|
||||
║ ║
|
||||
║ ${API_KEY}
|
||||
║ ║
|
||||
║ In Electron mode, authentication is handled automatically. ║
|
||||
║ ║
|
||||
║ 💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
╔═════════════════════════════════════════════════════════════════════╗
|
||||
║ ${header}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ ${line1}║
|
||||
║ ║
|
||||
║ ${line2}║
|
||||
║ ║
|
||||
║ ${line3}║
|
||||
║ ║
|
||||
║ ${line4}║
|
||||
║ ║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ${tipHeader}║
|
||||
╠═════════════════════════════════════════════════════════════════════╣
|
||||
║ ${line5}║
|
||||
║ ${line6}║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)');
|
||||
@@ -320,6 +346,15 @@ function checkAuthentication(
|
||||
return { authenticated: false, errorType: 'invalid_api_key' };
|
||||
}
|
||||
|
||||
// Check for session token in query parameter (web mode - needed for image loads)
|
||||
const queryToken = query.token;
|
||||
if (queryToken) {
|
||||
if (validateSession(queryToken)) {
|
||||
return { authenticated: true };
|
||||
}
|
||||
return { authenticated: false, errorType: 'invalid_session' };
|
||||
}
|
||||
|
||||
// Check for session cookie (web mode)
|
||||
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
||||
if (sessionToken && validateSession(sessionToken)) {
|
||||
@@ -335,8 +370,9 @@ function checkAuthentication(
|
||||
* Accepts either:
|
||||
* 1. X-API-Key header (for Electron mode)
|
||||
* 2. X-Session-Token header (for web mode with explicit token)
|
||||
* 3. apiKey query parameter (fallback for cases where headers can't be set)
|
||||
* 4. Session cookie (for web mode)
|
||||
* 3. apiKey query parameter (fallback for Electron, cases where headers can't be set)
|
||||
* 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags)
|
||||
* 5. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const result = checkAuthentication(
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
McpServerConfig,
|
||||
PromptCustomization,
|
||||
ClaudeApiProfile,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
@@ -345,3 +350,56 @@ export async function getCustomSubagents(
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
/** Result from getActiveClaudeApiProfile */
|
||||
export interface ActiveClaudeApiProfileResult {
|
||||
/** The active profile, or undefined if using direct Anthropic API */
|
||||
profile: ClaudeApiProfile | undefined;
|
||||
/** Credentials for resolving 'credentials' apiKeySource */
|
||||
credentials: import('@automaker/types').Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude API profile and credentials from 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]')
|
||||
* @returns Promise resolving to object with profile and credentials
|
||||
*/
|
||||
export async function getActiveClaudeApiProfile(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ActiveClaudeApiProfileResult> {
|
||||
if (!settingsService) {
|
||||
return { profile: undefined, credentials: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const profiles = globalSettings.claudeApiProfiles || [];
|
||||
const activeProfileId = globalSettings.activeClaudeApiProfileId;
|
||||
|
||||
// No active profile selected - use direct Anthropic API
|
||||
if (!activeProfileId) {
|
||||
return { profile: undefined, credentials };
|
||||
}
|
||||
|
||||
// Find the active profile by ID
|
||||
const activeProfile = profiles.find((p) => p.id === activeProfileId);
|
||||
|
||||
if (activeProfile) {
|
||||
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}`);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,14 @@
|
||||
|
||||
import * as secureFs from './secure-fs.js';
|
||||
import * as path from 'path';
|
||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { PRState, WorktreePRInfo };
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -10,7 +10,12 @@ import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||
import {
|
||||
getThinkingTokenBudget,
|
||||
validateBareModelId,
|
||||
type ClaudeApiProfile,
|
||||
type Credentials,
|
||||
} from '@automaker/types';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -21,9 +26,19 @@ import type {
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
// Authentication
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
// Endpoint configuration
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'API_TIMEOUT_MS',
|
||||
// Model mappings
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
// Traffic control
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
// System vars (always from process.env)
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
@@ -33,16 +48,108 @@ const ALLOWED_ENV_VARS = [
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
* Build environment for the SDK with only explicitly allowed variables.
|
||||
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
|
||||
* When no profile is provided, uses direct Anthropic API settings from process.env.
|
||||
*
|
||||
* @param profile - Optional Claude API profile for alternative endpoint configuration
|
||||
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
function buildEnv(
|
||||
profile?: ClaudeApiProfile,
|
||||
credentials?: Credentials
|
||||
): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
|
||||
if (profile) {
|
||||
// Use profile configuration (clean switch - don't inherit non-system vars from process.env)
|
||||
logger.debug('Building environment from Claude API profile:', {
|
||||
name: profile.name,
|
||||
apiKeySource: profile.apiKeySource ?? 'inline',
|
||||
});
|
||||
|
||||
// Resolve API key based on source strategy
|
||||
let apiKey: string | undefined;
|
||||
const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat
|
||||
|
||||
switch (source) {
|
||||
case 'inline':
|
||||
apiKey = profile.apiKey;
|
||||
break;
|
||||
case 'env':
|
||||
apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
break;
|
||||
case 'credentials':
|
||||
apiKey = credentials?.apiKeys?.anthropic;
|
||||
break;
|
||||
}
|
||||
|
||||
// Warn if no API key found
|
||||
if (!apiKey) {
|
||||
logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (profile.useAuthToken) {
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
|
||||
} else {
|
||||
env['ANTHROPIC_API_KEY'] = apiKey;
|
||||
}
|
||||
|
||||
// Endpoint configuration
|
||||
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
|
||||
|
||||
if (profile.timeoutMs) {
|
||||
env['API_TIMEOUT_MS'] = String(profile.timeoutMs);
|
||||
}
|
||||
|
||||
// Model mappings
|
||||
if (profile.modelMappings?.haiku) {
|
||||
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
|
||||
}
|
||||
if (profile.modelMappings?.sonnet) {
|
||||
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
|
||||
}
|
||||
if (profile.modelMappings?.opus) {
|
||||
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
|
||||
}
|
||||
|
||||
// Traffic control
|
||||
if (profile.disableNonessentialTraffic) {
|
||||
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
|
||||
}
|
||||
} else {
|
||||
// Use direct Anthropic API - two modes:
|
||||
// 1. API Key mode: ANTHROPIC_API_KEY from credentials/env
|
||||
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
|
||||
//
|
||||
// IMPORTANT: Do NOT set any profile vars (base URL, model mappings, etc.)
|
||||
// This ensures clean switching - only pass through what's in process.env
|
||||
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;
|
||||
}
|
||||
// Do NOT set ANTHROPIC_BASE_URL - let SDK use default Anthropic endpoint
|
||||
// Do NOT set model mappings - use standard Claude model names
|
||||
// Do NOT set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
}
|
||||
|
||||
// Always add system vars from process.env
|
||||
for (const key of SYSTEM_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -70,6 +177,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
conversationHistory,
|
||||
sdkSessionId,
|
||||
thinkingLevel,
|
||||
claudeApiProfile,
|
||||
credentials,
|
||||
} = options;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
@@ -82,7 +191,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// When a profile is active, uses profile settings (clean switch)
|
||||
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth)
|
||||
env: buildEnv(claudeApiProfile, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
|
||||
@@ -44,7 +44,7 @@ export class CursorConfigManager {
|
||||
|
||||
// Return default config with all available models
|
||||
return {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export class CursorConfigManager {
|
||||
* Get the default model
|
||||
*/
|
||||
getDefaultModel(): CursorModelId {
|
||||
return this.config.defaultModel || 'auto';
|
||||
return this.config.defaultModel || 'cursor-auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,7 @@ export class CursorConfigManager {
|
||||
* Get enabled models
|
||||
*/
|
||||
getEnabledModels(): CursorModelId[] {
|
||||
return this.config.models || ['auto'];
|
||||
return this.config.models || ['cursor-auto'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +174,7 @@ export class CursorConfigManager {
|
||||
*/
|
||||
reset(): void {
|
||||
this.config = {
|
||||
defaultModel: 'auto',
|
||||
defaultModel: 'cursor-auto',
|
||||
models: getAllCursorModelIds(),
|
||||
};
|
||||
this.saveConfig();
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
ContentBlock,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ClaudeApiProfile,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
|
||||
@@ -54,6 +56,10 @@ export interface SimpleQueryOptions {
|
||||
readOnly?: boolean;
|
||||
/** Setting sources for CLAUDE.md loading */
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
/** Active Claude API profile for alternative endpoint configuration */
|
||||
claudeApiProfile?: ClaudeApiProfile;
|
||||
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles */
|
||||
credentials?: Credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +131,8 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
@@ -207,6 +215,8 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
|
||||
@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
@@ -123,6 +127,12 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
|
||||
logger.info('Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
@@ -134,6 +144,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
thinkingLevel,
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
|
||||
@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -100,6 +104,12 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
|
||||
logger.info('Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
@@ -132,6 +142,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
thinkingLevel,
|
||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import {
|
||||
extractImplementedFeatures,
|
||||
@@ -157,6 +160,12 @@ export async function syncSpec(
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
@@ -185,6 +194,8 @@ Return ONLY this JSON format, no other text:
|
||||
thinkingLevel,
|
||||
readOnly: true,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
|
||||
@@ -117,9 +117,27 @@ export function createAuthRoutes(): Router {
|
||||
*
|
||||
* Returns whether the current request is authenticated.
|
||||
* Used by the UI to determine if login is needed.
|
||||
*
|
||||
* If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session
|
||||
* for unauthenticated requests (useful for development).
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
const authenticated = isRequestAuthenticated(req);
|
||||
router.get('/status', async (req, res) => {
|
||||
let authenticated = isRequestAuthenticated(req);
|
||||
|
||||
// Auto-login for development: create session automatically if enabled
|
||||
// Only works in non-production environments as a safeguard
|
||||
if (
|
||||
!authenticated &&
|
||||
process.env.AUTOMAKER_AUTO_LOGIN === 'true' &&
|
||||
process.env.NODE_ENV !== 'production'
|
||||
) {
|
||||
const sessionToken = await createSession();
|
||||
const cookieOptions = getSessionCookieOptions();
|
||||
const cookieName = getSessionCookieName();
|
||||
res.cookie(cookieName, sessionToken, cookieOptions);
|
||||
authenticated = true;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createStopFeatureHandler } from './routes/stop-feature.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createRunFeatureHandler } from './routes/run-feature.js';
|
||||
import { createStartHandler } from './routes/start.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createVerifyFeatureHandler } from './routes/verify-feature.js';
|
||||
import { createResumeFeatureHandler } from './routes/resume-feature.js';
|
||||
import { createContextExistsHandler } from './routes/context-exists.js';
|
||||
@@ -22,6 +24,10 @@ import { createResumeInterruptedHandler } from './routes/resume-interrupted.js';
|
||||
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Auto loop control routes
|
||||
router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService));
|
||||
router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService));
|
||||
|
||||
router.post('/stop-feature', createStopFeatureHandler(autoModeService));
|
||||
router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService));
|
||||
router.post(
|
||||
|
||||
54
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
54
apps/server/src/routes/auto-mode/routes/start.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /start endpoint - Start auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is already running for this project',
|
||||
alreadyRunning: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the auto loop for this project
|
||||
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
|
||||
|
||||
logger.info(
|
||||
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Start auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* POST /status endpoint - Get auto mode status
|
||||
*
|
||||
* If projectPath is provided, returns per-project status including autoloop state.
|
||||
* If no projectPath, returns global status for backward compatibility.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -9,10 +12,30 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
|
||||
// If projectPath is provided, return per-project status
|
||||
if (projectPath) {
|
||||
const projectStatus = autoModeService.getStatusForProject(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: projectStatus.runningCount > 0,
|
||||
isAutoLoopRunning: projectStatus.isAutoLoopRunning,
|
||||
runningFeatures: projectStatus.runningFeatures,
|
||||
runningCount: projectStatus.runningCount,
|
||||
maxConcurrency: projectStatus.maxConcurrency,
|
||||
projectPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
activeAutoLoopProjects: activeProjects,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
|
||||
54
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
54
apps/server/src/routes/auto-mode/routes/stop.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop auto mode loop for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if running
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is not running for this project',
|
||||
wasRunning: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the auto loop for this project
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
|
||||
|
||||
logger.info(
|
||||
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode stopped',
|
||||
runningFeaturesCount: runningCount,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Stop auto mode failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -100,11 +100,60 @@ export function getAbortController(): AbortController | null {
|
||||
return currentAbortController;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
/**
|
||||
* Map SDK/CLI errors to user-friendly messages
|
||||
*/
|
||||
export function mapBacklogPlanError(rawMessage: string): string {
|
||||
// Claude Code spawn failures
|
||||
if (
|
||||
rawMessage.includes('Failed to spawn Claude Code process') ||
|
||||
rawMessage.includes('spawn node ENOENT') ||
|
||||
rawMessage.includes('Claude Code executable not found') ||
|
||||
rawMessage.includes('Claude Code native binary not found')
|
||||
) {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
return String(error);
|
||||
|
||||
// Claude Code process crash
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) {
|
||||
return 'Rate limited. Please wait a moment and try again.';
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('network') ||
|
||||
rawMessage.toLowerCase().includes('econnrefused') ||
|
||||
rawMessage.toLowerCase().includes('timeout')
|
||||
) {
|
||||
return 'Network error. Check your internet connection and try again.';
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (
|
||||
rawMessage.toLowerCase().includes('not authenticated') ||
|
||||
rawMessage.toLowerCase().includes('unauthorized') ||
|
||||
rawMessage.includes('401')
|
||||
) {
|
||||
return 'Authentication failed. Please check your API key or run `claude login` to authenticate.';
|
||||
}
|
||||
|
||||
// Return original message for unknown errors
|
||||
return rawMessage;
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
let rawMessage: string;
|
||||
if (error instanceof Error) {
|
||||
rawMessage = error.message;
|
||||
} else {
|
||||
rawMessage = String(error);
|
||||
}
|
||||
return mapBacklogPlanError(rawMessage);
|
||||
}
|
||||
|
||||
export function logError(error: unknown, context: string): void {
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
saveBacklogPlan,
|
||||
} from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -161,6 +165,12 @@ ${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
}
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
prompt: finalPrompt,
|
||||
@@ -173,6 +183,8 @@ ${userPrompt}`;
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
|
||||
@@ -53,13 +53,12 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
@@ -165,6 +166,12 @@ ${contentToAnalyze}`;
|
||||
|
||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
const result = await simpleQuery({
|
||||
prompt,
|
||||
@@ -175,6 +182,8 @@ ${contentToAnalyze}`;
|
||||
thinkingLevel,
|
||||
readOnly: true, // File description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const description = result.text;
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
@@ -284,6 +285,12 @@ export function createDescribeImageHandler(
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Build the instruction text from centralized prompts
|
||||
const instructionText = prompts.contextDescription.describeImagePrompt;
|
||||
|
||||
@@ -325,6 +332,8 @@ export function createDescribeImageHandler(
|
||||
thinkingLevel,
|
||||
readOnly: true, // Image description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
|
||||
|
||||
@@ -12,7 +12,10 @@ import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
@@ -126,6 +129,12 @@ export function createEnhanceHandler(
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[EnhancePrompt]'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
@@ -137,6 +146,8 @@ export function createEnhanceHandler(
|
||||
allowedTools: [],
|
||||
thinkingLevel,
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const enhancedText = result.text;
|
||||
|
||||
@@ -10,7 +10,10 @@ import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
@@ -60,6 +63,12 @@ export function createGenerateTitleHandler(
|
||||
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
|
||||
const systemPrompt = prompts.titleGeneration.systemPrompt;
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[GenerateTitle]'
|
||||
);
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
||||
@@ -69,6 +78,8 @@ export function createGenerateTitleHandler(
|
||||
cwd: process.cwd(),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const title = result.text;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* GET /image endpoint - Serve image files
|
||||
*
|
||||
* Requires authentication via auth middleware:
|
||||
* - apiKey query parameter (Electron mode)
|
||||
* - token query parameter (web mode)
|
||||
* - session cookie (web mode)
|
||||
* - X-API-Key header (Electron mode)
|
||||
* - X-Session-Token header (web mode)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
ValidationComment,
|
||||
ValidationLinkedPR,
|
||||
} from './validation-schema.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
clearValidationStatus,
|
||||
@@ -43,7 +47,6 @@ import {
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
@@ -166,6 +169,12 @@ ${basePrompt}`;
|
||||
|
||||
logger.info(`Using model: ${model}`);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[IssueValidation]'
|
||||
);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
@@ -177,6 +186,8 @@ ${basePrompt}`;
|
||||
reasoningEffort: effectiveReasoningEffort,
|
||||
readOnly: true, // Issue validation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -45,18 +45,24 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
}
|
||||
|
||||
// Minimal debug logging to help diagnose accidental wipes.
|
||||
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
}
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
const trashedLen = Array.isArray((updates as any).trashedProjects)
|
||||
? (updates as any).trashedProjects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
|
||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
logger.info(
|
||||
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
|
||||
settings.projects?.length ?? 0
|
||||
);
|
||||
|
||||
// Apply server log level if it was updated
|
||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||
|
||||
@@ -15,7 +15,11 @@ import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
@@ -192,6 +196,12 @@ ${prompts.suggestions.baseTemplate}`;
|
||||
|
||||
logger.info('[Suggestions] Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
@@ -223,6 +233,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
thinkingLevel,
|
||||
readOnly: true, // Suggestions only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
createGetAvailableEditorsHandler,
|
||||
createRefreshEditorsHandler,
|
||||
} from './routes/open-in-editor.js';
|
||||
import {
|
||||
createOpenInTerminalHandler,
|
||||
createGetAvailableTerminalsHandler,
|
||||
createGetDefaultTerminalHandler,
|
||||
createRefreshTerminalsHandler,
|
||||
createOpenInExternalTerminalHandler,
|
||||
} from './routes/open-in-terminal.js';
|
||||
import { createInitGitHandler } from './routes/init-git.js';
|
||||
import { createMigrateHandler } from './routes/migrate.js';
|
||||
import { createStartDevHandler } from './routes/start-dev.js';
|
||||
@@ -97,9 +104,25 @@ export function createWorktreeRoutes(
|
||||
);
|
||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.post(
|
||||
'/open-in-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInTerminalHandler()
|
||||
);
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.get('/available-editors', createGetAvailableEditorsHandler());
|
||||
router.post('/refresh-editors', createRefreshEditorsHandler());
|
||||
|
||||
// External terminal routes
|
||||
router.get('/available-terminals', createGetAvailableTerminalsHandler());
|
||||
router.get('/default-terminal', createGetDefaultTerminalHandler());
|
||||
router.post('/refresh-terminals', createRefreshTerminalsHandler());
|
||||
router.post(
|
||||
'/open-in-external-terminal',
|
||||
validatePathParams('worktreePath'),
|
||||
createOpenInExternalTerminalHandler()
|
||||
);
|
||||
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
router.post('/migrate', createMigrateHandler());
|
||||
router.post(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../common.js';
|
||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { validatePRState } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CreatePR');
|
||||
|
||||
@@ -268,11 +269,12 @@ export function createCreatePRHandler() {
|
||||
prAlreadyExisted = true;
|
||||
|
||||
// Store the existing PR info in metadata
|
||||
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || 'open',
|
||||
state: validatePRState(existingPr.state),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
logger.debug(
|
||||
@@ -319,11 +321,12 @@ export function createCreatePRHandler() {
|
||||
|
||||
if (prNumber) {
|
||||
try {
|
||||
// Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN'
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: prNumber,
|
||||
url: prUrl,
|
||||
title,
|
||||
state: draft ? 'draft' : 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
||||
@@ -352,11 +355,12 @@ export function createCreatePRHandler() {
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
|
||||
// GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || 'open',
|
||||
state: validatePRState(existingPr.state),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
@@ -18,6 +17,7 @@ import { mergeCommitMessagePrompts } from '@automaker/prompts';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateCommitMessage');
|
||||
const execAsync = promisify(exec);
|
||||
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function createGenerateCommitMessageHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
@@ -195,57 +168,53 @@ export function createGenerateCommitMessageHandler(
|
||||
// Get the effective system prompt (custom or default)
|
||||
const systemPrompt = await getSystemPrompt(settingsService);
|
||||
|
||||
let message: string;
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[GenerateCommitMessage]'
|
||||
);
|
||||
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info(`Using Cursor provider for model: ${model}`);
|
||||
// Get provider for the model type
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
|
||||
const effectivePrompt = isCursorModel(model)
|
||||
? `${systemPrompt}\n\n${userPrompt}`
|
||||
: userPrompt;
|
||||
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
|
||||
|
||||
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
|
||||
|
||||
let responseText = '';
|
||||
const cursorStream = provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
cwd: worktreePath,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
});
|
||||
let responseText = '';
|
||||
const stream = provider.executeQuery({
|
||||
prompt: effectivePrompt,
|
||||
model: bareModel,
|
||||
cwd: worktreePath,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if available (some providers return final text here)
|
||||
responseText = msg.result;
|
||||
}
|
||||
|
||||
message = responseText.trim();
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
const message = responseText.trim();
|
||||
|
||||
if (!message || message.trim().length === 0) {
|
||||
logger.warn('Received empty response from model');
|
||||
const response: GenerateCommitMessageErrorResponse = {
|
||||
|
||||
@@ -14,8 +14,13 @@ import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import {
|
||||
readAllWorktreeMetadata,
|
||||
updateWorktreePRInfo,
|
||||
type WorktreePRInfo,
|
||||
} from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { validatePRState } from '@automaker/types';
|
||||
import {
|
||||
checkGitHubRemote,
|
||||
type GitHubRemoteStatus,
|
||||
@@ -168,8 +173,11 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch open PRs from GitHub and create a map of branch name to PR info.
|
||||
* This allows detecting PRs that were created outside the app.
|
||||
* Fetch all PRs from GitHub and create a map of branch name to PR info.
|
||||
* Uses --state all to include merged/closed PRs, allowing detection of
|
||||
* state changes (e.g., when a PR is merged on GitHub).
|
||||
*
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
@@ -192,9 +200,9 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
|
||||
: '';
|
||||
|
||||
// Fetch open PRs from GitHub
|
||||
// Fetch all PRs from GitHub (including merged/closed to detect state changes)
|
||||
const { stdout } = await execAsync(
|
||||
`gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
|
||||
`gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`,
|
||||
{ cwd: projectPath, env: execEnv, timeout: 15000 }
|
||||
);
|
||||
|
||||
@@ -212,7 +220,8 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
title: pr.title,
|
||||
state: pr.state,
|
||||
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
|
||||
state: validatePRState(pr.state),
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
@@ -351,23 +360,43 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR info from metadata or GitHub for each worktree
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
|
||||
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
// Skip PR assignment for the main worktree - it's not meaningful to show
|
||||
// PRs on the main branch tab, and can be confusing if someone created
|
||||
// a PR from main to another branch
|
||||
if (worktree.isMain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
if (metadata?.pr) {
|
||||
// Use stored metadata (more complete info)
|
||||
worktree.pr = metadata.pr;
|
||||
} else if (includeDetails) {
|
||||
// Fall back to GitHub PR detection only when includeDetails is requested
|
||||
const githubPR = githubPRs.get(worktree.branch);
|
||||
if (githubPR) {
|
||||
worktree.pr = githubPR;
|
||||
const githubPR = githubPRs.get(worktree.branch);
|
||||
|
||||
if (githubPR) {
|
||||
// Prefer fresh GitHub data (it has the current state)
|
||||
worktree.pr = githubPR;
|
||||
|
||||
// Sync metadata with GitHub state when:
|
||||
// 1. No metadata exists for this PR (PR created externally)
|
||||
// 2. State has changed (e.g., merged/closed on GitHub)
|
||||
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
|
||||
if (needsSync) {
|
||||
// Fire and forget - don't block the response
|
||||
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
|
||||
logger.warn(
|
||||
`Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
|
||||
// Fall back to stored metadata only if the PR is still OPEN
|
||||
worktree.pr = metadata.pr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
181
apps/server/src/routes/worktree/routes/open-in-terminal.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Terminal endpoints for opening worktree directories in terminals
|
||||
*
|
||||
* POST /open-in-terminal - Open in system default terminal (integrated)
|
||||
* GET /available-terminals - List all available external terminals
|
||||
* GET /default-terminal - Get the default external terminal
|
||||
* POST /refresh-terminals - Clear terminal cache and re-detect
|
||||
* POST /open-in-external-terminal - Open a directory in an external terminal
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { isAbsolute } from 'path';
|
||||
import {
|
||||
openInTerminal,
|
||||
clearTerminalCache,
|
||||
detectAllTerminals,
|
||||
detectDefaultTerminal,
|
||||
openInExternalTerminal,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('open-in-terminal');
|
||||
|
||||
/**
|
||||
* Handler to open in system default terminal (integrated terminal behavior)
|
||||
*/
|
||||
export function createOpenInTerminalHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Validate that worktreePath is an absolute path
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the platform utility to open in terminal
|
||||
const result = await openInTerminal(worktreePath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened terminal in ${worktreePath}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to get all available external terminals
|
||||
*/
|
||||
export function createGetAvailableTerminalsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminals = await detectAllTerminals();
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
terminals,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get available terminals failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to get the default external terminal
|
||||
*/
|
||||
export function createGetDefaultTerminalHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const terminal = await detectDefaultTerminal();
|
||||
res.json({
|
||||
success: true,
|
||||
result: terminal
|
||||
? {
|
||||
terminalId: terminal.id,
|
||||
terminalName: terminal.name,
|
||||
terminalCommand: terminal.command,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get default terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to refresh the terminal cache and re-detect available terminals
|
||||
* Useful when the user has installed/uninstalled terminals
|
||||
*/
|
||||
export function createRefreshTerminalsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Clear the cache
|
||||
clearTerminalCache();
|
||||
|
||||
// Re-detect terminals (this will repopulate the cache)
|
||||
const terminals = await detectAllTerminals();
|
||||
|
||||
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
terminals,
|
||||
message: `Found ${terminals.length} available external terminals`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh terminals failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler to open a directory in an external terminal
|
||||
*/
|
||||
export function createOpenInExternalTerminalHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, terminalId } = req.body as {
|
||||
worktreePath: string;
|
||||
terminalId?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAbsolute(worktreePath)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath must be an absolute path',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await openInExternalTerminal(worktreePath, terminalId);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
message: `Opened ${worktreePath} in ${result.terminalName}`,
|
||||
terminalName: result.terminalName,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Open in external terminal failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -274,6 +275,12 @@ export class AgentService {
|
||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||
: undefined;
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||
// Use the user's message as task context for smart memory selection
|
||||
const contextResult = await loadContextFiles({
|
||||
@@ -378,6 +385,8 @@ export class AgentService {
|
||||
agents: customSubagents, // Pass custom subagents for task delegation
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
|
||||
@@ -235,6 +236,17 @@ interface AutoModeConfig {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-project autoloop state for multi-project support
|
||||
*/
|
||||
interface ProjectAutoLoopState {
|
||||
abortController: AbortController;
|
||||
config: AutoModeConfig;
|
||||
isRunning: boolean;
|
||||
consecutiveFailures: { timestamp: number; error: string }[];
|
||||
pausedDueToFailures: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution state for recovery after server restart
|
||||
* Tracks which features were running and auto-loop configuration
|
||||
@@ -267,12 +279,15 @@ export class AutoModeService {
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private autoLoop: AutoLoopState | null = null;
|
||||
private featureLoader = new FeatureLoader();
|
||||
// Per-project autoloop state (supports multiple concurrent projects)
|
||||
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
||||
// Legacy single-project properties (kept for backward compatibility during transition)
|
||||
private autoLoopRunning = false;
|
||||
private autoLoopAbortController: AbortController | null = null;
|
||||
private config: AutoModeConfig | null = null;
|
||||
private pendingApprovals = new Map<string, PendingApproval>();
|
||||
private settingsService: SettingsService | null = null;
|
||||
// Track consecutive failures to detect quota/API issues
|
||||
// Track consecutive failures to detect quota/API issues (legacy global, now per-project in autoLoopsByProject)
|
||||
private consecutiveFailures: { timestamp: number; error: string }[] = [];
|
||||
private pausedDueToFailures = false;
|
||||
|
||||
@@ -284,6 +299,44 @@ export class AutoModeService {
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* This handles cases where the SDK doesn't return useful error messages.
|
||||
* @param projectPath - The project to track failure for
|
||||
* @param errorInfo - Error information
|
||||
*/
|
||||
private trackFailureAndCheckPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): boolean {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
// Fall back to legacy global tracking
|
||||
return this.trackFailureAndCheckPause(errorInfo);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures (legacy global).
|
||||
*/
|
||||
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
|
||||
const now = Date.now();
|
||||
@@ -311,7 +364,49 @@ export class AutoModeService {
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop to prevent repeated failures.
|
||||
* This will pause the auto loop for a specific project.
|
||||
* @param projectPath - The project to pause
|
||||
* @param errorInfo - Error information
|
||||
*/
|
||||
private signalShouldPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
// Fall back to legacy global pause
|
||||
this.signalShouldPause(errorInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectState.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
projectState.pausedDueToFailures = true;
|
||||
const failureCount = projectState.consecutiveFailures.length;
|
||||
logger.info(
|
||||
`Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop for this project
|
||||
this.stopAutoLoopForProject(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion (legacy global).
|
||||
*/
|
||||
private signalShouldPause(errorInfo: { type: string; message: string }): void {
|
||||
if (this.pausedDueToFailures) {
|
||||
@@ -341,7 +436,19 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode)
|
||||
* Reset failure tracking for a specific project
|
||||
* @param projectPath - The project to reset failure tracking for
|
||||
*/
|
||||
private resetFailureTrackingForProject(projectPath: string): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
projectState.pausedDueToFailures = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking (called when user manually restarts auto mode) - legacy global
|
||||
*/
|
||||
private resetFailureTracking(): void {
|
||||
this.consecutiveFailures = [];
|
||||
@@ -349,16 +456,255 @@ export class AutoModeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count
|
||||
* Record a successful feature completion to reset consecutive failure count for a project
|
||||
* @param projectPath - The project to record success for
|
||||
*/
|
||||
private recordSuccessForProject(projectPath: string): void {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count - legacy global
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.consecutiveFailures = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop for a specific project (supports multiple concurrent projects)
|
||||
* @param projectPath - The project to start auto mode for
|
||||
* @param maxConcurrency - Maximum concurrent features (default: 3)
|
||||
*/
|
||||
async startAutoLoopForProject(projectPath: string, maxConcurrency = 3): Promise<void> {
|
||||
// Check if this project already has an active autoloop
|
||||
const existingState = this.autoLoopsByProject.get(projectPath);
|
||||
if (existingState?.isRunning) {
|
||||
throw new Error(`Auto mode is already running for project: ${projectPath}`);
|
||||
}
|
||||
|
||||
// Create new project autoloop state
|
||||
const abortController = new AbortController();
|
||||
const config: AutoModeConfig = {
|
||||
maxConcurrency,
|
||||
useWorktrees: true,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
const projectState: ProjectAutoLoopState = {
|
||||
abortController,
|
||||
config,
|
||||
isRunning: true,
|
||||
consecutiveFailures: [],
|
||||
pausedDueToFailures: false,
|
||||
};
|
||||
|
||||
this.autoLoopsByProject.set(projectPath, projectState);
|
||||
|
||||
logger.info(
|
||||
`Starting auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency}`
|
||||
);
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_started', {
|
||||
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Save execution state for recovery after restart
|
||||
await this.saveExecutionStateForProject(projectPath, maxConcurrency);
|
||||
|
||||
// Run the loop in the background
|
||||
this.runAutoLoopForProject(projectPath).catch((error) => {
|
||||
logger.error(`Loop error for ${projectPath}:`, error);
|
||||
const errorInfo = classifyError(error);
|
||||
this.emitAutoModeEvent('auto_mode_error', {
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the auto loop for a specific project
|
||||
*/
|
||||
private async runAutoLoopForProject(projectPath: string): Promise<void> {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
logger.warn(`No project state found for ${projectPath}, stopping loop`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AutoLoop] Starting loop for ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
|
||||
);
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
// Count running features for THIS project only
|
||||
const projectRunningCount = this.getRunningCountForProject(projectPath);
|
||||
|
||||
// Check if we have capacity for this project
|
||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||
logger.debug(
|
||||
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
|
||||
);
|
||||
await this.sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load pending features for this project
|
||||
const pendingFeatures = await this.loadPendingFeatures(projectPath);
|
||||
|
||||
logger.debug(
|
||||
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount} running`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
this.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
});
|
||||
logger.info(`[AutoLoop] No pending features, sleeping for 10s...`);
|
||||
await this.sleep(10000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a feature not currently running
|
||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
||||
|
||||
if (nextFeature) {
|
||||
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
||||
// Start feature execution in background
|
||||
this.executeFeature(
|
||||
projectPath,
|
||||
nextFeature.id,
|
||||
projectState.config.useWorktrees,
|
||||
true
|
||||
).catch((error) => {
|
||||
logger.error(`Feature ${nextFeature.id} error:`, error);
|
||||
});
|
||||
} else {
|
||||
logger.debug(`[AutoLoop] All pending features are already running`);
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
} catch (error) {
|
||||
logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error);
|
||||
await this.sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as not running when loop exits
|
||||
projectState.isRunning = false;
|
||||
logger.info(
|
||||
`[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running features for a specific project
|
||||
*/
|
||||
private getRunningCountForProject(projectPath: string): number {
|
||||
let count = 0;
|
||||
for (const [, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath === projectPath) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop for a specific project
|
||||
* @param projectPath - The project to stop auto mode for
|
||||
*/
|
||||
async stopAutoLoopForProject(projectPath: string): Promise<number> {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
if (!projectState) {
|
||||
logger.warn(`No auto loop running for project: ${projectPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wasRunning = projectState.isRunning;
|
||||
projectState.isRunning = false;
|
||||
projectState.abortController.abort();
|
||||
|
||||
// Clear execution state when auto-loop is explicitly stopped
|
||||
await this.clearExecutionState(projectPath);
|
||||
|
||||
// Emit stop event
|
||||
if (wasRunning) {
|
||||
this.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from map
|
||||
this.autoLoopsByProject.delete(projectPath);
|
||||
|
||||
return this.getRunningCountForProject(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto mode is running for a specific project
|
||||
*/
|
||||
isAutoLoopRunningForProject(projectPath: string): boolean {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
return projectState?.isRunning ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto loop config for a specific project
|
||||
*/
|
||||
getAutoLoopConfigForProject(projectPath: string): AutoModeConfig | null {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
return projectState?.config ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save execution state for a specific project
|
||||
*/
|
||||
private async saveExecutionStateForProject(
|
||||
projectPath: string,
|
||||
maxConcurrency: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const statePath = getExecutionStatePath(projectPath);
|
||||
const runningFeatureIds = Array.from(this.runningFeatures.entries())
|
||||
.filter(([, f]) => f.projectPath === projectPath)
|
||||
.map(([id]) => id);
|
||||
|
||||
const state: ExecutionState = {
|
||||
version: 1,
|
||||
autoLoopWasRunning: true,
|
||||
maxConcurrency,
|
||||
projectPath,
|
||||
runningFeatureIds,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
|
||||
logger.info(
|
||||
`Saved execution state for ${projectPath}: ${runningFeatureIds.length} running features`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save execution state for ${projectPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
* @deprecated Use startAutoLoopForProject instead for multi-project support
|
||||
*/
|
||||
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
|
||||
// For backward compatibility, delegate to the new per-project method
|
||||
// But also maintain legacy state for existing code that might check it
|
||||
if (this.autoLoopRunning) {
|
||||
throw new Error('Auto mode is already running');
|
||||
}
|
||||
@@ -396,6 +742,9 @@ export class AutoModeService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use runAutoLoopForProject instead
|
||||
*/
|
||||
private async runAutoLoop(): Promise<void> {
|
||||
while (
|
||||
this.autoLoopRunning &&
|
||||
@@ -448,6 +797,7 @@ export class AutoModeService {
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop
|
||||
* @deprecated Use stopAutoLoopForProject instead for multi-project support
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
const wasRunning = this.autoLoopRunning;
|
||||
@@ -1708,6 +2058,12 @@ Format your response as a structured markdown document.`;
|
||||
thinkingLevel: analysisThinkingLevel,
|
||||
});
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
|
||||
const options: ExecuteOptions = {
|
||||
prompt,
|
||||
model: sdkOptions.model ?? analysisModel,
|
||||
@@ -1717,6 +2073,8 @@ Format your response as a structured markdown document.`;
|
||||
abortController,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(options);
|
||||
@@ -1777,6 +2135,46 @@ Format your response as a structured markdown document.`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for a specific project
|
||||
* @param projectPath - The project to get status for
|
||||
*/
|
||||
getStatusForProject(projectPath: string): {
|
||||
isAutoLoopRunning: boolean;
|
||||
runningFeatures: string[];
|
||||
runningCount: number;
|
||||
maxConcurrency: number;
|
||||
} {
|
||||
const projectState = this.autoLoopsByProject.get(projectPath);
|
||||
const runningFeatures: string[] = [];
|
||||
|
||||
for (const [featureId, feature] of this.runningFeatures) {
|
||||
if (feature.projectPath === projectPath) {
|
||||
runningFeatures.push(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isAutoLoopRunning: projectState?.isRunning ?? false,
|
||||
runningFeatures,
|
||||
runningCount: runningFeatures.length,
|
||||
maxConcurrency: projectState?.config.maxConcurrency ?? 3,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projects that have auto mode running
|
||||
*/
|
||||
getActiveAutoLoopProjects(): string[] {
|
||||
const activeProjects: string[] = [];
|
||||
for (const [projectPath, state] of this.autoLoopsByProject) {
|
||||
if (state.isRunning) {
|
||||
activeProjects.push(projectPath);
|
||||
}
|
||||
}
|
||||
return activeProjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed info about all running agents
|
||||
*/
|
||||
@@ -2254,6 +2652,10 @@ Format your response as a structured markdown document.`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status`
|
||||
);
|
||||
|
||||
// Apply dependency-aware ordering
|
||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||
|
||||
@@ -2266,8 +2668,13 @@ Format your response as a structured markdown document.`;
|
||||
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
|
||||
);
|
||||
|
||||
return readyFeatures;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error(`[loadPendingFeatures] Error loading features:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2536,6 +2943,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
);
|
||||
}
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: promptContent,
|
||||
model: bareModel,
|
||||
@@ -2547,6 +2960,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
settingSources: sdkOptions.settingSources,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
// Execute via provider
|
||||
@@ -2849,6 +3264,7 @@ After generating the revised spec, output:
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
});
|
||||
|
||||
let revisionText = '';
|
||||
@@ -2994,6 +3410,7 @@ After generating the revised spec, output:
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
});
|
||||
|
||||
let taskOutput = '';
|
||||
@@ -3088,6 +3505,7 @@ After generating the revised spec, output:
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
});
|
||||
|
||||
for await (const msg of continuationStream) {
|
||||
|
||||
@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -223,6 +223,12 @@ export class IdeationService {
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: message,
|
||||
model: bareModel,
|
||||
@@ -232,6 +238,8 @@ export class IdeationService {
|
||||
maxTurns: 1, // Single turn for ideation
|
||||
abortController: activeSession.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
@@ -678,6 +686,12 @@ export class IdeationService {
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: prompt.prompt,
|
||||
model: bareModel,
|
||||
@@ -688,6 +702,8 @@ export class IdeationService {
|
||||
// Disable all tools - we just want text generation, not codebase analysis
|
||||
allowedTools: [],
|
||||
abortController: new AbortController(),
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import {
|
||||
getGlobalSettingsPath,
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from '../types/settings.js';
|
||||
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
@@ -124,10 +128,14 @@ export class SettingsService {
|
||||
// Migrate legacy enhancementModel/validationModel to phaseModels
|
||||
const migratedPhaseModels = this.migratePhaseModels(settings);
|
||||
|
||||
// Migrate model IDs to canonical format
|
||||
const migratedModelSettings = this.migrateModelSettings(settings);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
...migratedModelSettings,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
@@ -158,6 +166,41 @@ export class SettingsService {
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
|
||||
// If user has an Anthropic API key in credentials but no profiles, create a
|
||||
// "Direct Anthropic" profile that references the credentials and set it as active.
|
||||
if (storedVersion < 5) {
|
||||
try {
|
||||
const credentials = await this.getCredentials();
|
||||
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
|
||||
const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
|
||||
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
|
||||
|
||||
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
|
||||
const directAnthropicProfile = {
|
||||
id: `profile-${Date.now()}-direct-anthropic`,
|
||||
name: 'Direct Anthropic',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKeySource: 'credentials' as const,
|
||||
useAuthToken: false,
|
||||
};
|
||||
|
||||
result.claudeApiProfiles = [directAnthropicProfile];
|
||||
result.activeClaudeApiProfileId = directAnthropicProfile.id;
|
||||
|
||||
logger.info(
|
||||
'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'Migration v4->v5: Could not check credentials for auto-profile creation:',
|
||||
error
|
||||
);
|
||||
}
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Update version if any migration occurred
|
||||
if (needsSave) {
|
||||
result.version = SETTINGS_VERSION;
|
||||
@@ -223,19 +266,70 @@ export class SettingsService {
|
||||
* Convert a phase model value to PhaseModelEntry format
|
||||
*
|
||||
* Handles migration from string format (v2) to object format (v3).
|
||||
* - String values like 'sonnet' become { model: 'sonnet' }
|
||||
* - Object values are returned as-is (with type assertion)
|
||||
* Also migrates legacy model IDs to canonical prefixed format.
|
||||
* - String values like 'sonnet' become { model: 'claude-sonnet' }
|
||||
* - Object values have their model ID migrated if needed
|
||||
*
|
||||
* @param value - Phase model value (string or PhaseModelEntry)
|
||||
* @returns PhaseModelEntry object
|
||||
* @returns PhaseModelEntry object with canonical model ID
|
||||
*/
|
||||
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
|
||||
if (typeof value === 'string') {
|
||||
// v2 format: just a model string
|
||||
return { model: value as PhaseModelEntry['model'] };
|
||||
// v2 format: just a model string - migrate to canonical ID
|
||||
return { model: migrateModelId(value) as PhaseModelEntry['model'] };
|
||||
}
|
||||
// v3 format: already a PhaseModelEntry object
|
||||
return value;
|
||||
// v3 format: PhaseModelEntry object - migrate model ID if needed
|
||||
return {
|
||||
...value,
|
||||
model: migrateModelId(value.model) as PhaseModelEntry['model'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate model-related settings to canonical format
|
||||
*
|
||||
* Migrates:
|
||||
* - enabledCursorModels: legacy IDs to cursor- prefixed
|
||||
* - enabledOpencodeModels: legacy slash format to dash format
|
||||
* - cursorDefaultModel: legacy ID to cursor- prefixed
|
||||
*
|
||||
* @param settings - Settings to migrate
|
||||
* @returns Settings with migrated model IDs
|
||||
*/
|
||||
private migrateModelSettings(settings: Partial<GlobalSettings>): Partial<GlobalSettings> {
|
||||
const migrated: Partial<GlobalSettings> = { ...settings };
|
||||
|
||||
// Migrate Cursor models
|
||||
if (settings.enabledCursorModels) {
|
||||
migrated.enabledCursorModels = migrateCursorModelIds(
|
||||
settings.enabledCursorModels as string[]
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate Cursor default model
|
||||
if (settings.cursorDefaultModel) {
|
||||
const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]);
|
||||
if (migratedDefault.length > 0) {
|
||||
migrated.cursorDefaultModel = migratedDefault[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate OpenCode models
|
||||
if (settings.enabledOpencodeModels) {
|
||||
migrated.enabledOpencodeModels = migrateOpencodeModelIds(
|
||||
settings.enabledOpencodeModels as string[]
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate OpenCode default model
|
||||
if (settings.opencodeDefaultModel) {
|
||||
const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]);
|
||||
if (migratedDefault.length > 0) {
|
||||
migrated.opencodeDefaultModel = migratedDefault[0];
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,13 +367,39 @@ export class SettingsService {
|
||||
};
|
||||
|
||||
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
|
||||
// Check if this is a legitimate project removal (moved to trash) vs accidental wipe
|
||||
const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects)
|
||||
? sanitizedUpdates.trashedProjects.length
|
||||
: Array.isArray(current.trashedProjects)
|
||||
? current.trashedProjects.length
|
||||
: 0;
|
||||
|
||||
if (
|
||||
Array.isArray(sanitizedUpdates.projects) &&
|
||||
sanitizedUpdates.projects.length === 0 &&
|
||||
currentProjectsLen > 0
|
||||
) {
|
||||
attemptedProjectWipe = true;
|
||||
delete sanitizedUpdates.projects;
|
||||
// Only treat as accidental wipe if trashedProjects is also empty
|
||||
// (If projects are moved to trash, they appear in trashedProjects)
|
||||
if (newTrashedProjectsLen === 0) {
|
||||
logger.warn(
|
||||
'[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.',
|
||||
{
|
||||
currentProjectsLen,
|
||||
newProjectsLen: 0,
|
||||
newTrashedProjectsLen,
|
||||
currentProjects: current.projects?.map((p) => p.name),
|
||||
}
|
||||
);
|
||||
attemptedProjectWipe = true;
|
||||
delete sanitizedUpdates.projects;
|
||||
} else {
|
||||
logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', {
|
||||
currentProjectsLen,
|
||||
newProjectsLen: 0,
|
||||
movedToTrash: newTrashedProjectsLen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ignoreEmptyArrayOverwrite('trashedProjects');
|
||||
@@ -766,4 +886,203 @@ export class SettingsService {
|
||||
getDataDir(): string {
|
||||
return this.dataDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the legacy Electron userData directory path
|
||||
*
|
||||
* Returns the platform-specific path where Electron previously stored settings
|
||||
* before the migration to shared data directories.
|
||||
*
|
||||
* @returns Absolute path to legacy userData directory
|
||||
*/
|
||||
private getLegacyElectronUserDataPath(): string {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
// macOS: ~/Library/Application Support/Automaker
|
||||
return path.join(homeDir, 'Library', 'Application Support', 'Automaker');
|
||||
case 'win32':
|
||||
// Windows: %APPDATA%\Automaker
|
||||
return path.join(
|
||||
process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||
'Automaker'
|
||||
);
|
||||
default:
|
||||
// Linux and others: ~/.config/Automaker
|
||||
return path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), 'Automaker');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate entire data directory from legacy Electron userData location to new shared data directory
|
||||
*
|
||||
* This handles the migration from when Electron stored data in the platform-specific
|
||||
* userData directory (e.g., ~/.config/Automaker) to the new shared ./data directory.
|
||||
*
|
||||
* Migration only occurs if:
|
||||
* 1. The new location does NOT have settings.json
|
||||
* 2. The legacy location DOES have settings.json
|
||||
*
|
||||
* Migrates all files and directories including:
|
||||
* - settings.json (global settings)
|
||||
* - credentials.json (API keys)
|
||||
* - sessions-metadata.json (chat session metadata)
|
||||
* - agent-sessions/ (conversation histories)
|
||||
* - Any other files in the data directory
|
||||
*
|
||||
* @returns Promise resolving to migration result
|
||||
*/
|
||||
async migrateFromLegacyElectronPath(): Promise<{
|
||||
migrated: boolean;
|
||||
migratedFiles: string[];
|
||||
legacyPath: string;
|
||||
errors: string[];
|
||||
}> {
|
||||
const legacyPath = this.getLegacyElectronUserDataPath();
|
||||
const migratedFiles: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Skip if legacy path is the same as current data dir (no migration needed)
|
||||
if (path.resolve(legacyPath) === path.resolve(this.dataDir)) {
|
||||
logger.debug('Legacy path same as current data dir, skipping migration');
|
||||
return { migrated: false, migratedFiles, legacyPath, errors };
|
||||
}
|
||||
|
||||
logger.info(`Checking for legacy data migration from: ${legacyPath}`);
|
||||
logger.info(`Current data directory: ${this.dataDir}`);
|
||||
|
||||
// Check if new settings already exist
|
||||
const newSettingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
let newSettingsExist = false;
|
||||
try {
|
||||
await fs.access(newSettingsPath);
|
||||
newSettingsExist = true;
|
||||
} catch {
|
||||
// New settings don't exist, migration may be needed
|
||||
}
|
||||
|
||||
if (newSettingsExist) {
|
||||
logger.debug('Settings already exist in new location, skipping migration');
|
||||
return { migrated: false, migratedFiles, legacyPath, errors };
|
||||
}
|
||||
|
||||
// Check if legacy directory exists and has settings
|
||||
const legacySettingsPath = path.join(legacyPath, 'settings.json');
|
||||
let legacySettingsExist = false;
|
||||
try {
|
||||
await fs.access(legacySettingsPath);
|
||||
legacySettingsExist = true;
|
||||
} catch {
|
||||
// Legacy settings don't exist
|
||||
}
|
||||
|
||||
if (!legacySettingsExist) {
|
||||
logger.debug('No legacy settings found, skipping migration');
|
||||
return { migrated: false, migratedFiles, legacyPath, errors };
|
||||
}
|
||||
|
||||
// Perform migration of specific application data files only
|
||||
// (not Electron internal caches like Code Cache, GPU Cache, etc.)
|
||||
logger.info('Found legacy data directory, migrating application data to new location...');
|
||||
|
||||
// Ensure new data directory exists
|
||||
try {
|
||||
await ensureDataDir(this.dataDir);
|
||||
} catch (error) {
|
||||
const msg = `Failed to create data directory: ${error}`;
|
||||
logger.error(msg);
|
||||
errors.push(msg);
|
||||
return { migrated: false, migratedFiles, legacyPath, errors };
|
||||
}
|
||||
|
||||
// Only migrate specific application data files/directories
|
||||
const itemsToMigrate = [
|
||||
'settings.json',
|
||||
'credentials.json',
|
||||
'sessions-metadata.json',
|
||||
'agent-sessions',
|
||||
'.api-key',
|
||||
'.sessions',
|
||||
];
|
||||
|
||||
for (const item of itemsToMigrate) {
|
||||
const srcPath = path.join(legacyPath, item);
|
||||
const destPath = path.join(this.dataDir, item);
|
||||
|
||||
// Check if source exists
|
||||
try {
|
||||
await fs.access(srcPath);
|
||||
} catch {
|
||||
// Source doesn't exist, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if destination already exists
|
||||
try {
|
||||
await fs.access(destPath);
|
||||
logger.debug(`Skipping ${item} - already exists in destination`);
|
||||
continue;
|
||||
} catch {
|
||||
// Destination doesn't exist, proceed with copy
|
||||
}
|
||||
|
||||
// Copy file or directory
|
||||
try {
|
||||
const stat = await fs.stat(srcPath);
|
||||
if (stat.isDirectory()) {
|
||||
await this.copyDirectory(srcPath, destPath);
|
||||
migratedFiles.push(item + '/');
|
||||
logger.info(`Migrated directory: ${item}/`);
|
||||
} else {
|
||||
const content = await fs.readFile(srcPath);
|
||||
await fs.writeFile(destPath, content);
|
||||
migratedFiles.push(item);
|
||||
logger.info(`Migrated file: ${item}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = `Failed to migrate ${item}: ${error}`;
|
||||
logger.error(msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedFiles.length > 0) {
|
||||
logger.info(
|
||||
`Migration complete. Migrated ${migratedFiles.length} item(s): ${migratedFiles.join(', ')}`
|
||||
);
|
||||
logger.info(`Legacy path: ${legacyPath}`);
|
||||
logger.info(`New path: ${this.dataDir}`);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: migratedFiles.length > 0,
|
||||
migratedFiles,
|
||||
legacyPath,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory from source to destination
|
||||
*
|
||||
* @param srcDir - Source directory path
|
||||
* @param destDir - Destination directory path
|
||||
*/
|
||||
private async copyDirectory(srcDir: string, destDir: string): Promise<void> {
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(srcDir, entry.name);
|
||||
const destPath = path.join(destDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(srcPath, destPath);
|
||||
} else if (entry.isFile()) {
|
||||
const content = await fs.readFile(srcPath);
|
||||
await fs.writeFile(destPath, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('model-resolver.ts', () => {
|
||||
const result = resolveModelString('opus');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Resolved Claude model alias: "opus"')
|
||||
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 123,
|
||||
url: 'https://github.com/owner/repo/pull/123',
|
||||
title: 'Test PR',
|
||||
state: 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -158,7 +158,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 456,
|
||||
url: 'https://github.com/owner/repo/pull/456',
|
||||
title: 'Updated PR',
|
||||
state: 'closed',
|
||||
state: 'CLOSED',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
@@ -177,7 +177,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 789,
|
||||
url: 'https://github.com/owner/repo/pull/789',
|
||||
title: 'New PR',
|
||||
state: 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 999,
|
||||
url: 'https://github.com/owner/repo/pull/999',
|
||||
title: 'Updated PR',
|
||||
state: 'merged',
|
||||
state: 'MERGED',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 111,
|
||||
url: 'https://github.com/owner/repo/pull/111',
|
||||
title: 'PR',
|
||||
state: 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 222,
|
||||
url: 'https://github.com/owner/repo/pull/222',
|
||||
title: 'Has PR',
|
||||
state: 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('worktree-metadata.ts', () => {
|
||||
number: 333,
|
||||
url: 'https://github.com/owner/repo/pull/333',
|
||||
title: 'PR 3',
|
||||
state: 'open',
|
||||
state: 'OPEN',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,8 +50,8 @@ describe('cursor-config-manager.ts', () => {
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
const config = manager.getConfig();
|
||||
expect(config.defaultModel).toBe('auto');
|
||||
expect(config.models).toContain('auto');
|
||||
expect(config.defaultModel).toBe('cursor-auto');
|
||||
expect(config.models).toContain('cursor-auto');
|
||||
});
|
||||
|
||||
it('should use default config if file read fails', () => {
|
||||
@@ -62,7 +62,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
|
||||
it('should use default config if JSON parse fails', () => {
|
||||
@@ -71,7 +71,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
});
|
||||
|
||||
it('should return default model', () => {
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
|
||||
it('should set and persist default model', () => {
|
||||
@@ -103,13 +103,13 @@ describe('cursor-config-manager.ts', () => {
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return auto if defaultModel is undefined', () => {
|
||||
it('should return cursor-auto if defaultModel is undefined', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['auto'] }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] }));
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
it('should return enabled models', () => {
|
||||
const models = manager.getEnabledModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models).toContain('auto');
|
||||
expect(models).toContain('cursor-auto');
|
||||
});
|
||||
|
||||
it('should set enabled models', () => {
|
||||
@@ -131,13 +131,13 @@ describe('cursor-config-manager.ts', () => {
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return [auto] if models is undefined', () => {
|
||||
it('should return [cursor-auto] if models is undefined', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
|
||||
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
expect(manager.getEnabledModels()).toEqual(['auto']);
|
||||
expect(manager.getEnabledModels()).toEqual(['cursor-auto']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,8 +146,8 @@ describe('cursor-config-manager.ts', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
JSON.stringify({
|
||||
defaultModel: 'auto',
|
||||
models: ['auto'],
|
||||
defaultModel: 'cursor-auto',
|
||||
models: ['cursor-auto'],
|
||||
})
|
||||
);
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
@@ -161,14 +161,14 @@ describe('cursor-config-manager.ts', () => {
|
||||
});
|
||||
|
||||
it('should not add duplicate models', () => {
|
||||
manager.addModel('auto');
|
||||
manager.addModel('cursor-auto');
|
||||
|
||||
// Should not save if model already exists
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize models array if undefined', () => {
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' }));
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' }));
|
||||
manager = new CursorConfigManager(testProjectPath);
|
||||
|
||||
manager.addModel('claude-3-5-sonnet');
|
||||
@@ -293,7 +293,7 @@ describe('cursor-config-manager.ts', () => {
|
||||
it('should reset to default values', () => {
|
||||
manager.reset();
|
||||
|
||||
expect(manager.getDefaultModel()).toBe('auto');
|
||||
expect(manager.getDefaultModel()).toBe('cursor-auto');
|
||||
expect(manager.getMcpServers()).toEqual([]);
|
||||
expect(manager.getRules()).toEqual([]);
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
|
||||
@@ -647,9 +647,10 @@ describe('settings-service.ts', () => {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Verify all phase models are now PhaseModelEntry objects
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Legacy aliases are migrated to canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
expect(settings.version).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
@@ -675,16 +676,17 @@ describe('settings-service.ts', () => {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Verify PhaseModelEntry objects are preserved with thinkingLevel
|
||||
// Legacy aliases are migrated to canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({
|
||||
model: 'opus',
|
||||
model: 'claude-opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
});
|
||||
expect(settings.phaseModels.backlogPlanningModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'medium',
|
||||
});
|
||||
});
|
||||
@@ -710,15 +712,15 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Strings should be converted to objects
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'haiku' });
|
||||
// Objects should be preserved
|
||||
// Strings should be converted to objects with canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
// Objects should be preserved with migrated IDs
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({
|
||||
model: 'haiku',
|
||||
model: 'claude-haiku',
|
||||
thinkingLevel: 'low',
|
||||
});
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should migrate legacy enhancementModel/validationModel fields', async () => {
|
||||
@@ -735,11 +737,11 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Legacy fields should be migrated to phaseModels
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'opus' });
|
||||
// Other fields should use defaults
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Legacy fields should be migrated to phaseModels with canonical IDs
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' });
|
||||
// Other fields should use defaults (canonical IDs)
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should use default phase models when none are configured', async () => {
|
||||
@@ -753,10 +755,10 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Should use DEFAULT_PHASE_MODELS
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'opus' });
|
||||
// Should use DEFAULT_PHASE_MODELS (with canonical IDs)
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' });
|
||||
expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' });
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' });
|
||||
});
|
||||
|
||||
it('should deep merge phaseModels on update', async () => {
|
||||
@@ -776,13 +778,13 @@ describe('settings-service.ts', () => {
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
// Both should be preserved
|
||||
// Both should be preserved (models migrated to canonical format)
|
||||
expect(settings.phaseModels.enhancementModel).toEqual({
|
||||
model: 'sonnet',
|
||||
model: 'claude-sonnet',
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
expect(settings.phaseModels.specGenerationModel).toEqual({
|
||||
model: 'opus',
|
||||
model: 'claude-opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/spec-parser": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -279,7 +280,7 @@ export function ClaudeUsagePopover() {
|
||||
) : !claudeUsage ? (
|
||||
// Loading state
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -333,7 +334,7 @@ export function CodexUsagePopover() {
|
||||
) : !codexUsage ? (
|
||||
// Loading state
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : codexUsage.rateLimits ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
|
||||
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
const logger = createLogger('BoardBackgroundModal');
|
||||
import {
|
||||
@@ -313,7 +314,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
/>
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -353,7 +354,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -14,16 +14,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
FolderPlus,
|
||||
FolderOpen,
|
||||
Rocket,
|
||||
ExternalLink,
|
||||
Check,
|
||||
Loader2,
|
||||
Link,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -451,7 +443,7 @@ export function NewProjectModal({
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
|
||||
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
|
||||
interface WorkspaceDirectory {
|
||||
@@ -74,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
||||
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
|
||||
<Spinner size="xl" />
|
||||
<p className="text-sm text-muted-foreground">Loading projects...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal file
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { ComponentType, ComponentProps } from 'react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
|
||||
type IconProps = ComponentProps<'svg'>;
|
||||
type IconComponent = ComponentType<IconProps>;
|
||||
|
||||
/**
|
||||
* iTerm2 logo icon
|
||||
*/
|
||||
export function ITerm2Icon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2.586 0a2.56 2.56 0 00-2.56 2.56v18.88A2.56 2.56 0 002.586 24h18.88a2.56 2.56 0 002.56-2.56V2.56A2.56 2.56 0 0021.466 0H2.586zm8.143 4.027h2.543v15.946h-2.543V4.027zm-3.816 0h2.544v15.946H6.913V4.027zm7.633 0h2.543v15.946h-2.543V4.027z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warp terminal logo icon
|
||||
*/
|
||||
export function WarpIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M9.2 4.8L7.6 7.6 4.8 5.6l1.6-2.8L9.2 4.8zm5.6 0l1.6 2.8 2.8-2-1.6-2.8-2.8 2zM2.4 12l2.8 1.6L3.6 16 .8 14.4 2.4 12zm19.2 0l1.6 2.4-2.8 1.6-1.6-2.4 2.8-1.6zM7.6 16.4l1.6 2.8-2.8 2-1.6-2.8 2.8-2zm8.8 0l2.8 2-1.6 2.8-2.8-2 1.6-2.8zM12 0L8.4 2 12 4l3.6-2L12 0zm0 20l-3.6 2 3.6 2 3.6-2-3.6-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghostty terminal logo icon
|
||||
*/
|
||||
export function GhosttyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-5.52-4.48-10-10-10zm-3.5 12a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM12 19c-1.5 0-3-.5-4-1.5v-1c2 1 6 1 8 0v1c-1 1-2.5 1.5-4 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alacritty terminal logo icon
|
||||
*/
|
||||
export function AlacrittyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M12 0L1.608 21.6h3.186l1.46-3.032h11.489l1.46 3.032h3.189L12 0zm0 7.29l3.796 7.882H8.204L12 7.29z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WezTerm terminal logo icon
|
||||
*/
|
||||
export function WezTermIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2 2h12v2H6V8zm0 4h8v2H6v-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty terminal logo icon
|
||||
*/
|
||||
export function KittyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3.5 7.5L1 5V2.5L3.5 5V7.5zM20.5 7.5L23 5V2.5L20.5 5V7.5zM12 4L6 8v8l6 4 6-4V8l-6-4zm0 2l4 2.67v5.33L12 16.67 8 14V8.67L12 6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hyper terminal logo icon
|
||||
*/
|
||||
export function HyperIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M11.857 23.995v-7.125H6.486l5.765-10.856-.363-1.072L7.803.001 0 12.191h5.75L0 23.995h11.857zm.286 0h5.753l5.679-11.804h-5.679l5.679-11.804L17.896.388l-5.753 11.803h5.753L12.143 24z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabby terminal logo icon
|
||||
*/
|
||||
export function TabbyIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2l6 3v10l-6 3-6-3V7l6-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rio terminal logo icon
|
||||
*/
|
||||
export function RioIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows Terminal logo icon
|
||||
*/
|
||||
export function WindowsTerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M8.165 6L0 9.497v5.006L8.165 18l.413-.206v-4.025L3.197 12l5.381-1.769V6.206L8.165 6zm7.67 0l-.413.206v4.025L20.803 12l-5.381 1.769v4.025l.413.206L24 14.503V9.497L15.835 6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PowerShell logo icon
|
||||
*/
|
||||
export function PowerShellIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035h18.23zM8.958 16.677c0 .334.276.611.611.611h3.673a.615.615 0 00.611-.611.615.615 0 00-.611-.611h-3.673a.615.615 0 00-.611.611zm5.126-7.016L9.025 14.72c-.241.241-.241.63 0 .872.241.241.63.241.872 0l5.059-5.059c.241-.241.241-.63 0-.872l-5.059-5.059c-.241-.241-.63-.241-.872 0-.241.241-.241.63 0 .872l5.059 5.059c-.334.334-.334.334 0 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Prompt (cmd) logo icon
|
||||
*/
|
||||
export function CmdIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2.5 1.5l3 3-3 3L5 12l3-3zm5.5 5h6v1.5h-6V12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Bash logo icon
|
||||
*/
|
||||
export function GitBashIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GNOME Terminal logo icon
|
||||
*/
|
||||
export function GnomeTerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2 4a2 2 0 00-2 2v12a2 2 0 002 2h20a2 2 0 002-2V6a2 2 0 00-2-2H2zm0 2h20v12H2V6zm2 2v2h2V8H4zm4 0v2h12V8H8zm-4 4v2h2v-2H4zm4 0v2h8v-2H8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konsole logo icon
|
||||
*/
|
||||
export function KonsoleIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3 3h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5a2 2 0 012-2zm0 2v14h18V5H3zm2 2l4 4-4 4V7zm6 6h8v2h-8v-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Terminal logo icon
|
||||
*/
|
||||
export function MacOSTerminalIcon(props: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M3 4a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2H3zm0 2h18v12H3V6zm2 2l5 4-5 4V8zm7 6h7v2h-7v-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon component for a terminal ID
|
||||
*/
|
||||
export function getTerminalIcon(terminalId: string): IconComponent {
|
||||
const terminalIcons: Record<string, IconComponent> = {
|
||||
iterm2: ITerm2Icon,
|
||||
warp: WarpIcon,
|
||||
ghostty: GhosttyIcon,
|
||||
alacritty: AlacrittyIcon,
|
||||
wezterm: WezTermIcon,
|
||||
kitty: KittyIcon,
|
||||
hyper: HyperIcon,
|
||||
tabby: TabbyIcon,
|
||||
rio: RioIcon,
|
||||
'windows-terminal': WindowsTerminalIcon,
|
||||
powershell: PowerShellIcon,
|
||||
cmd: CmdIcon,
|
||||
'git-bash': GitBashIcon,
|
||||
'gnome-terminal': GnomeTerminalIcon,
|
||||
konsole: KonsoleIcon,
|
||||
'terminal-macos': MacOSTerminalIcon,
|
||||
// Linux terminals - use generic terminal icon
|
||||
'xfce4-terminal': Terminal,
|
||||
tilix: Terminal,
|
||||
terminator: Terminal,
|
||||
foot: Terminal,
|
||||
xterm: Terminal,
|
||||
};
|
||||
|
||||
return terminalIcons[terminalId] ?? Terminal;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Folder, LucideIcon } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, sanitizeForTestId } from '@/lib/utils';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
@@ -37,10 +37,15 @@ export function ProjectSwitcherItem({
|
||||
const IconComponent = getIconComponent();
|
||||
const hasCustomIcon = !!project.customIconPath;
|
||||
|
||||
// Combine project.id with sanitized name for uniqueness and readability
|
||||
// Format: project-switcher-{id}-{sanitizedName}
|
||||
const testId = `project-switcher-${project.id}-${sanitizeForTestId(project.name)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
|
||||
'transition-all duration-200 ease-out',
|
||||
@@ -60,7 +65,6 @@ export function ProjectSwitcherItem({
|
||||
'hover:scale-105 active:scale-95'
|
||||
)}
|
||||
title={project.name}
|
||||
data-testid={`project-switcher-${project.id}`}
|
||||
>
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||
import { ProjectContextMenu } from './components/project-context-menu';
|
||||
@@ -10,7 +10,7 @@ import { EditProjectDialog } from './components/edit-project-dialog';
|
||||
import { NotificationBell } from './components/notification-bell';
|
||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
|
||||
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
|
||||
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -41,7 +41,6 @@ export function ProjectSwitcher() {
|
||||
projects,
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
trashedProjects,
|
||||
upsertAndSetCurrentProject,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
@@ -69,9 +68,6 @@ export function ProjectSwitcher() {
|
||||
const appMode = import.meta.env.VITE_APP_MODE || '?';
|
||||
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
|
||||
|
||||
// Get global theme for project creation
|
||||
const { globalTheme } = useProjectTheme();
|
||||
|
||||
// Project creation state and handlers
|
||||
const {
|
||||
showNewProjectModal,
|
||||
@@ -84,9 +80,6 @@ export function ProjectSwitcher() {
|
||||
handleCreateFromTemplate,
|
||||
handleCreateFromCustomUrl,
|
||||
} = useProjectCreation({
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
});
|
||||
|
||||
@@ -161,13 +154,8 @@ export function ProjectSwitcher() {
|
||||
}
|
||||
|
||||
// Upsert project and set as current (handles both create and update cases)
|
||||
// Theme preservation is handled by the store action
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
||||
upsertAndSetCurrentProject(path, name);
|
||||
|
||||
// Check if app_spec.txt exists
|
||||
const specExists = await hasAppSpec(path);
|
||||
@@ -198,7 +186,7 @@ export function ProjectSwitcher() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
|
||||
}, [upsertAndSetCurrentProject, navigate]);
|
||||
|
||||
// Handler for creating initial spec from the setup dialog
|
||||
const handleCreateInitialSpec = useCallback(async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
|
||||
const logger = createLogger('Sidebar');
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
useProjectCreation,
|
||||
useSetupDialog,
|
||||
useTrashOperations,
|
||||
useProjectTheme,
|
||||
useUnviewedValidations,
|
||||
} from './sidebar/hooks';
|
||||
|
||||
@@ -79,9 +78,6 @@ export function Sidebar() {
|
||||
// State for trash dialog
|
||||
const [showTrashDialog, setShowTrashDialog] = useState(false);
|
||||
|
||||
// Project theme management (must come before useProjectCreation which uses globalTheme)
|
||||
const { globalTheme } = useProjectTheme();
|
||||
|
||||
// Project creation state and handlers
|
||||
const {
|
||||
showNewProjectModal,
|
||||
@@ -97,9 +93,6 @@ export function Sidebar() {
|
||||
handleCreateFromTemplate,
|
||||
handleCreateFromCustomUrl,
|
||||
} = useProjectCreation({
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
});
|
||||
|
||||
@@ -198,13 +191,8 @@ export function Sidebar() {
|
||||
}
|
||||
|
||||
// Upsert project and set as current (handles both create and update cases)
|
||||
// Theme preservation is handled by the store action
|
||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||
// Theme handling (trashed project recovery or undefined for global) is done by the store
|
||||
upsertAndSetCurrentProject(path, name);
|
||||
|
||||
// Check if app_spec.txt exists
|
||||
const specExists = await hasAppSpec(path);
|
||||
@@ -232,7 +220,7 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
|
||||
}, [upsertAndSetCurrentProject]);
|
||||
|
||||
// Navigation sections and keyboard shortcuts (defined after handlers)
|
||||
const { navSections, navigationShortcuts } = useNavigation({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
@@ -93,9 +93,10 @@ export function SidebarNavigation({
|
||||
>
|
||||
<div className="relative">
|
||||
{item.isLoading ? (
|
||||
<Loader2
|
||||
<Spinner
|
||||
size="md"
|
||||
className={cn(
|
||||
'w-[18px] h-[18px] shrink-0 animate-spin',
|
||||
'shrink-0',
|
||||
isActive ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -6,20 +6,13 @@ const logger = createLogger('ProjectCreation');
|
||||
import { initializeProject } from '@/lib/project-init';
|
||||
import { toast } from 'sonner';
|
||||
import type { StarterTemplate } from '@/lib/templates';
|
||||
import type { ThemeMode } from '@/store/app-store';
|
||||
import type { TrashedProject, Project } from '@/lib/electron';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseProjectCreationProps {
|
||||
trashedProjects: TrashedProject[];
|
||||
currentProject: Project | null;
|
||||
globalTheme: ThemeMode;
|
||||
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
|
||||
upsertAndSetCurrentProject: (path: string, name: string) => Project;
|
||||
}
|
||||
|
||||
export function useProjectCreation({
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
globalTheme,
|
||||
upsertAndSetCurrentProject,
|
||||
}: UseProjectCreationProps) {
|
||||
// Modal state
|
||||
@@ -67,14 +60,8 @@ export function useProjectCreation({
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme: try trashed project theme, then current project theme, then global
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
// Let the store handle theme (trashed project recovery or undefined for global)
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
|
||||
setShowNewProjectModal(false);
|
||||
|
||||
@@ -92,7 +79,7 @@ export function useProjectCreation({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
[upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -169,14 +156,8 @@ export function useProjectCreation({
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
// Let the store handle theme (trashed project recovery or undefined for global)
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
setShowNewProjectModal(false);
|
||||
setNewProjectName(projectName);
|
||||
setNewProjectPath(projectPath);
|
||||
@@ -194,7 +175,7 @@ export function useProjectCreation({
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
[upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -244,14 +225,8 @@ export function useProjectCreation({
|
||||
</project_specification>`
|
||||
);
|
||||
|
||||
// Determine theme
|
||||
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
|
||||
const effectiveTheme =
|
||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||
(currentProject?.theme as ThemeMode | undefined) ||
|
||||
globalTheme;
|
||||
|
||||
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
|
||||
// Let the store handle theme (trashed project recovery or undefined for global)
|
||||
upsertAndSetCurrentProject(projectPath, projectName);
|
||||
setShowNewProjectModal(false);
|
||||
setNewProjectName(projectName);
|
||||
setNewProjectPath(projectPath);
|
||||
@@ -269,7 +244,7 @@ export function useProjectCreation({
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
},
|
||||
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
|
||||
[upsertAndSetCurrentProject]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
Check,
|
||||
X,
|
||||
ArchiveRestore,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionListItem } from '@/types/electron';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
@@ -466,7 +466,7 @@ export function SessionManager({
|
||||
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||
{(currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||
runningSessions.has(session.id) ? (
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
||||
<Spinner size="sm" className="shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
@@ -39,7 +39,7 @@ const buttonVariants = cva(
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
|
||||
return <Spinner size="sm" className={className} />;
|
||||
}
|
||||
|
||||
function Button({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('DescriptionImageDropZone');
|
||||
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
|
||||
import { ImageIcon, X, FileText } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
@@ -431,7 +432,7 @@ export function DescriptionImageDropZone({
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
<span>Processing files...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('FeatureImageUpload');
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
@@ -196,7 +197,7 @@ export function FeatureImageUpload({
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" />
|
||||
) : (
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
FilePen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from './button';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
@@ -484,7 +484,7 @@ export function GitDiffPanel({
|
||||
<div className="border-t border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<Spinner size="md" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('ImageDropZone');
|
||||
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { ImageAttachment } from '@/store/app-store';
|
||||
import {
|
||||
fileToBase64,
|
||||
@@ -204,7 +205,7 @@ export function ImageDropZone({
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface LoadingStateProps {
|
||||
/** Optional custom message to display below the spinner */
|
||||
message?: string;
|
||||
/** Optional custom size class for the spinner (default: h-8 w-8) */
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
|
||||
export function LoadingState({ message }: LoadingStateProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
|
||||
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
|
||||
<Spinner size="xl" />
|
||||
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
Filter,
|
||||
Circle,
|
||||
Play,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
parseLogOutput,
|
||||
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
|
||||
case 'in_progress':
|
||||
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
|
||||
return <Spinner size="sm" />;
|
||||
case 'pending':
|
||||
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
|
||||
default:
|
||||
|
||||
@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
|
||||
// Cursor models - canonical format includes 'cursor-' prefix
|
||||
// Also support legacy IDs for backward compatibility
|
||||
if (
|
||||
modelStr.includes('cursor') ||
|
||||
modelStr === 'auto' ||
|
||||
modelStr === 'composer-1' ||
|
||||
modelStr === 'cursor-auto' ||
|
||||
modelStr === 'cursor-composer-1'
|
||||
) {
|
||||
return 'cursor';
|
||||
}
|
||||
|
||||
|
||||
32
apps/ui/src/components/ui/spinner.tsx
Normal file
32
apps/ui/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
xl: 'h-8 w-8',
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: SpinnerSize;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themed spinner component using the primary brand color.
|
||||
* Use this for all loading indicators throughout the app for consistency.
|
||||
*/
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('TaskProgressPanel');
|
||||
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
||||
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -260,7 +261,7 @@ export function TaskProgressPanel({
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{isActive && <Spinner size="xs" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting that uses CSS variables from the app's theme system
|
||||
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// XML tags - use primary color
|
||||
{ tag: t.tagName, color: 'var(--primary)' },
|
||||
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
|
||||
|
||||
// Attributes
|
||||
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
|
||||
// Strings and content
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
|
||||
// Special
|
||||
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
// Simple editor theme - inherits text color from parent
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '1rem',
|
||||
minHeight: '100%',
|
||||
caretColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
|
||||
'.cm-gutters': {
|
||||
display: 'none',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
extensions={[xml(), editorTheme]}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
|
||||
import { getTerminalTheme, getTerminalFontFamily } from '@/config/terminal-themes';
|
||||
|
||||
// Types for dynamically imported xterm modules
|
||||
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
|
||||
@@ -20,7 +20,7 @@ export interface XtermLogViewerRef {
|
||||
export interface XtermLogViewerProps {
|
||||
/** Initial content to display */
|
||||
initialContent?: string;
|
||||
/** Font size in pixels (default: 13) */
|
||||
/** Font size in pixels (uses terminal settings if not provided) */
|
||||
fontSize?: number;
|
||||
/** Whether to auto-scroll to bottom when new content is added (default: true) */
|
||||
autoScroll?: boolean;
|
||||
@@ -42,7 +42,7 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
|
||||
(
|
||||
{
|
||||
initialContent,
|
||||
fontSize = 13,
|
||||
fontSize,
|
||||
autoScroll = true,
|
||||
className,
|
||||
minHeight = 300,
|
||||
@@ -58,9 +58,14 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
|
||||
const autoScrollRef = useRef(autoScroll);
|
||||
const pendingContentRef = useRef<string[]>([]);
|
||||
|
||||
// Get theme from store
|
||||
// Get theme and font settings from store
|
||||
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
const terminalFontFamily = useAppStore((state) => state.terminalState.fontFamily);
|
||||
const terminalFontSize = useAppStore((state) => state.terminalState.defaultFontSize);
|
||||
|
||||
// Use prop if provided, otherwise use store value, fallback to 13
|
||||
const effectiveFontSize = fontSize ?? terminalFontSize ?? 13;
|
||||
|
||||
// Track system dark mode for "system" theme
|
||||
const [systemIsDark, setSystemIsDark] = useState(() => {
|
||||
@@ -102,12 +107,17 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
|
||||
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
// Get font settings from store at initialization time
|
||||
const terminalState = useAppStore.getState().terminalState;
|
||||
const fontFamily = getTerminalFontFamily(terminalState.fontFamily);
|
||||
const initFontSize = fontSize ?? terminalState.defaultFontSize ?? 13;
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'underline',
|
||||
cursorInactiveStyle: 'none',
|
||||
fontSize,
|
||||
fontFamily: DEFAULT_TERMINAL_FONT,
|
||||
fontSize: initFontSize,
|
||||
fontFamily,
|
||||
lineHeight: 1.2,
|
||||
theme: terminalTheme,
|
||||
disableStdin: true, // Read-only mode
|
||||
@@ -181,10 +191,18 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
|
||||
// Update font size when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
xtermRef.current.options.fontSize = fontSize;
|
||||
xtermRef.current.options.fontSize = effectiveFontSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
}, [fontSize, isReady]);
|
||||
}, [effectiveFontSize, isReady]);
|
||||
|
||||
// Update font family when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
xtermRef.current.options.fontFamily = getTerminalFontFamily(terminalFontFamily);
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
}, [terminalFontFamily, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -449,7 +450,7 @@ export function UsagePopover() {
|
||||
</div>
|
||||
) : !claudeUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -568,7 +569,7 @@ export function UsagePopover() {
|
||||
</div>
|
||||
) : !codexUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : codexUsage.rateLimits ? (
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
File,
|
||||
Pencil,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
@@ -236,7 +236,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isReadingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Reading...
|
||||
</>
|
||||
) : (
|
||||
@@ -315,7 +315,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isWritingFile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Writing...
|
||||
</>
|
||||
) : (
|
||||
@@ -383,7 +383,7 @@ export function AgentToolsView() {
|
||||
>
|
||||
{isRunningCommand ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Running...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AgentView() {
|
||||
return () => window.removeEventListener('resize', updateVisibility);
|
||||
}, []);
|
||||
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
|
||||
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||
|
||||
// Input ref for auto-focus
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Bot } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
@@ -8,20 +9,7 @@ export function ThinkingIndicator() {
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
FileCode,
|
||||
Loader2,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('AnalysisView');
|
||||
@@ -742,7 +742,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
@@ -771,7 +771,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
</div>
|
||||
) : isAnalyzing ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
|
||||
<Spinner size="xl" className="mb-4" />
|
||||
<p className="text-muted-foreground">Scanning project files...</p>
|
||||
</div>
|
||||
) : projectAnalysis ? (
|
||||
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
>
|
||||
{isGeneratingSpec ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
>
|
||||
{isGeneratingFeatureList ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
@@ -856,68 +856,9 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation]
|
||||
);
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
useEffect(() => {
|
||||
autoModeRunningRef.current = autoMode.isRunning;
|
||||
}, [autoMode.isRunning]);
|
||||
|
||||
// Use a ref to track the latest features to avoid effect re-runs when features change
|
||||
const hookFeaturesRef = useRef(hookFeatures);
|
||||
useEffect(() => {
|
||||
hookFeaturesRef.current = hookFeatures;
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
|
||||
const runningAutoTasksRef = useRef(runningAutoTasks);
|
||||
useEffect(() => {
|
||||
runningAutoTasksRef.current = runningAutoTasks;
|
||||
}, [runningAutoTasks]);
|
||||
|
||||
// Keep latest start handler without retriggering the auto mode effect
|
||||
const handleStartImplementationRef = useRef(handleStartImplementation);
|
||||
useEffect(() => {
|
||||
handleStartImplementationRef.current = handleStartImplementation;
|
||||
}, [handleStartImplementation]);
|
||||
|
||||
// Track features that are pending (started but not yet confirmed running)
|
||||
const pendingFeaturesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Listen to auto mode events to remove features from pending when they start running
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Only process events for the current project
|
||||
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
|
||||
if (eventProjectPath && eventProjectPath !== currentProject.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'auto_mode_feature_start':
|
||||
// Feature is now confirmed running - remove from pending
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auto_mode_feature_complete':
|
||||
case 'auto_mode_error':
|
||||
// Feature completed or errored - remove from pending if still there
|
||||
if (event.featureId) {
|
||||
pendingFeaturesRef.current.delete(event.featureId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
// NOTE: Auto mode polling loop has been moved to the backend.
|
||||
// The frontend now just toggles the backend's auto loop via API calls.
|
||||
// See use-auto-mode.ts for the start/stop logic that calls the backend.
|
||||
|
||||
// Listen for backlog plan events (for background generation)
|
||||
useEffect(() => {
|
||||
@@ -976,219 +917,6 @@ export function BoardView() {
|
||||
};
|
||||
}, [currentProject, pendingBacklogPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
'[AutoMode] Effect triggered - isRunning:',
|
||||
autoMode.isRunning,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
if (!autoMode.isRunning || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
|
||||
let isChecking = false;
|
||||
let isActive = true; // Track if this effect is still active
|
||||
|
||||
const checkAndStartFeatures = async () => {
|
||||
// Check if auto mode is still running and effect is still active
|
||||
// Use ref to get the latest value, not the closure value
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent executions
|
||||
if (isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
// Double-check auto mode is still running before proceeding
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
logger.debug(
|
||||
'[AutoMode] Skipping check - isActive:',
|
||||
isActive,
|
||||
'autoModeRunning:',
|
||||
autoModeRunningRef.current,
|
||||
'hasProject:',
|
||||
!!currentProject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently running tasks + pending features
|
||||
// Use ref to get the latest running tasks without causing effect re-runs
|
||||
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
|
||||
const availableSlots = maxConcurrency - currentRunning;
|
||||
logger.debug(
|
||||
'[AutoMode] Checking features - running:',
|
||||
currentRunning,
|
||||
'available slots:',
|
||||
availableSlots
|
||||
);
|
||||
|
||||
// No available slots, skip check
|
||||
if (availableSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter backlog features by the currently selected worktree branch
|
||||
// This logic mirrors use-board-column-features.ts for consistency.
|
||||
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
|
||||
// so we fall back to "all backlog features" when none are visible in the current view.
|
||||
// Use ref to get the latest features without causing effect re-runs
|
||||
const currentFeatures = hookFeaturesRef.current;
|
||||
const backlogFeaturesInView = currentFeatures.filter((f) => {
|
||||
if (f.status !== 'backlog') return false;
|
||||
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only when viewing primary worktree
|
||||
const isViewingPrimary = currentWorktreePath === null;
|
||||
return isViewingPrimary;
|
||||
}
|
||||
|
||||
if (currentWorktreeBranch === null) {
|
||||
// We're viewing main but branch hasn't been initialized yet
|
||||
// Show features assigned to primary worktree's branch
|
||||
return currentProject.path
|
||||
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
|
||||
: false;
|
||||
}
|
||||
|
||||
// Match by branch name
|
||||
return featureBranch === currentWorktreeBranch;
|
||||
});
|
||||
|
||||
const backlogFeatures =
|
||||
backlogFeaturesInView.length > 0
|
||||
? backlogFeaturesInView
|
||||
: currentFeatures.filter((f) => f.status === 'backlog');
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Features - total:',
|
||||
currentFeatures.length,
|
||||
'backlog in view:',
|
||||
backlogFeaturesInView.length,
|
||||
'backlog total:',
|
||||
backlogFeatures.length
|
||||
);
|
||||
|
||||
if (backlogFeatures.length === 0) {
|
||||
logger.debug(
|
||||
'[AutoMode] No backlog features found, statuses:',
|
||||
currentFeatures.map((f) => f.status).join(', ')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority (lower number = higher priority, priority 1 is highest)
|
||||
const sortedBacklog = [...backlogFeatures].sort(
|
||||
(a, b) => (a.priority || 999) - (b.priority || 999)
|
||||
);
|
||||
|
||||
// Filter out features with blocking dependencies if dependency blocking is enabled
|
||||
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
|
||||
// should NOT exclude blocked features in that mode.
|
||||
const eligibleFeatures =
|
||||
enableDependencyBlocking && !skipVerificationInAutoMode
|
||||
? sortedBacklog.filter((f) => {
|
||||
const blockingDeps = getBlockingDependencies(f, currentFeatures);
|
||||
if (blockingDeps.length > 0) {
|
||||
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
|
||||
}
|
||||
return blockingDeps.length === 0;
|
||||
})
|
||||
: sortedBacklog;
|
||||
|
||||
logger.debug(
|
||||
'[AutoMode] Eligible features after dep check:',
|
||||
eligibleFeatures.length,
|
||||
'dependency blocking enabled:',
|
||||
enableDependencyBlocking
|
||||
);
|
||||
|
||||
// Start features up to available slots
|
||||
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
|
||||
const startImplementation = handleStartImplementationRef.current;
|
||||
if (!startImplementation) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[AutoMode] Starting',
|
||||
featuresToStart.length,
|
||||
'features:',
|
||||
featuresToStart.map((f) => f.id).join(', ')
|
||||
);
|
||||
|
||||
for (const feature of featuresToStart) {
|
||||
// Check again before starting each feature
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
|
||||
// If feature has no branchName, assign it to the primary branch so it can run consistently
|
||||
// even when the user is viewing a non-primary worktree.
|
||||
if (!feature.branchName) {
|
||||
const primaryBranch =
|
||||
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
|
||||
'main';
|
||||
await persistFeatureUpdate(feature.id, {
|
||||
branchName: primaryBranch,
|
||||
});
|
||||
}
|
||||
|
||||
// Final check before starting implementation
|
||||
if (!isActive || !autoModeRunningRef.current || !currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the implementation - server will derive workDir from feature.branchName
|
||||
const started = await startImplementation(feature);
|
||||
|
||||
// If successfully started, track it as pending until we receive the start event
|
||||
if (started) {
|
||||
pendingFeaturesRef.current.add(feature.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately, then every 3 seconds
|
||||
checkAndStartFeatures();
|
||||
const interval = setInterval(checkAndStartFeatures, 3000);
|
||||
|
||||
return () => {
|
||||
// Mark as inactive to prevent any pending async operations from continuing
|
||||
isActive = false;
|
||||
clearInterval(interval);
|
||||
// Clear pending features when effect unmounts or dependencies change
|
||||
pendingFeaturesRef.current.clear();
|
||||
};
|
||||
}, [
|
||||
autoMode.isRunning,
|
||||
currentProject,
|
||||
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
|
||||
// that would clear pendingFeaturesRef and cause concurrency issues
|
||||
maxConcurrency,
|
||||
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
|
||||
currentWorktreeBranch,
|
||||
currentWorktreePath,
|
||||
getPrimaryWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
persistFeatureUpdate,
|
||||
]);
|
||||
|
||||
// Use keyboard shortcuts hook (after actions hook)
|
||||
useBoardKeyboardShortcuts({
|
||||
features: hookFeatures,
|
||||
@@ -1384,7 +1112,7 @@ export function BoardView() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1403,9 +1131,13 @@ export function BoardView() {
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
autoMode.start();
|
||||
autoMode.start().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to start:', error);
|
||||
});
|
||||
} else {
|
||||
autoMode.stop();
|
||||
autoMode.stop().catch((error) => {
|
||||
logger.error('[AutoMode] Failed to stop:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onOpenPlanDialog={() => setShowPlanDialog(true)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface BoardSearchBarProps {
|
||||
searchQuery: string;
|
||||
@@ -75,7 +76,7 @@ export function BoardSearchBar({
|
||||
title="Creating App Specification"
|
||||
data-testid="spec-creation-badge"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||
Creating spec
|
||||
</span>
|
||||
|
||||
@@ -11,16 +11,8 @@ import {
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import {
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
Expand,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
@@ -338,7 +330,7 @@ export function AgentInfoPanel({
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
GripVertical,
|
||||
Edit,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
ChevronUp,
|
||||
GitFork,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
@@ -65,7 +65,7 @@ export function CardHeaderSection({
|
||||
{isCurrentAutoTask && !isSelectionMode && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
{feature.startedAt && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
@@ -324,7 +324,7 @@ export function CardHeaderSection({
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||
</div>
|
||||
) : feature.title ? (
|
||||
|
||||
@@ -170,7 +170,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
@@ -353,7 +354,7 @@ export function AgentOutputModal({
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<Spinner size="md" />
|
||||
)}
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
@@ -439,7 +440,7 @@ export function AgentOutputModal({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
@@ -457,7 +458,7 @@ export function AgentOutputModal({
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
|
||||
@@ -11,16 +11,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Loader2,
|
||||
Wand2,
|
||||
Check,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -287,8 +279,7 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
{isGeneratingPlan && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
|
||||
the background...
|
||||
<Spinner size="sm" />A plan is currently being generated in the background...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -405,7 +396,7 @@ export function BacklogPlanDialog({
|
||||
case 'applying':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
|
||||
<Spinner size="xl" className="mb-4" />
|
||||
<p className="text-muted-foreground">Applying changes...</p>
|
||||
</div>
|
||||
);
|
||||
@@ -452,7 +443,7 @@ export function BacklogPlanDialog({
|
||||
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
|
||||
{isGeneratingPlan ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||
import { GitCommit, Sparkles } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { GitBranchPlus, Loader2 } from 'lucide-react';
|
||||
import { GitBranchPlus } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
|
||||
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -405,7 +406,7 @@ export function CreatePRDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { migrateModelId } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
@@ -107,9 +108,9 @@ export function EditFeatureDialog({
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
// Model selection state - migrate legacy model IDs to canonical format
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature?.model) || 'claude-opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
@@ -157,9 +158,9 @@ export function EditFeatureDialog({
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
// Reset model entry - migrate legacy model IDs
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature.model) || 'claude-opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MassEditDialog({
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -160,7 +160,7 @@ export function MassEditDialog({
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
|
||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface PlanApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
||||
import { AnsiOutput } from '@/components/ui/ansi-output';
|
||||
@@ -65,7 +66,7 @@ function SingleIndicator({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
|
||||
{status === 'running' && <Spinner size="sm" />}
|
||||
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
|
||||
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
||||
<span className="font-medium text-sm">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
@@ -90,9 +91,11 @@ function UsageItem({
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Refresh usage"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-6 space-y-2">{children}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
@@ -17,23 +17,27 @@ export type ModelOption = {
|
||||
hasThinking?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude models with canonical prefixed IDs
|
||||
* UI displays short labels but stores full canonical IDs
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: 'haiku',
|
||||
id: 'claude-haiku', // Canonical prefixed ID
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
id: 'claude-sonnet', // Canonical prefixed ID
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
id: 'claude-opus', // Canonical prefixed ID
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
|
||||
/**
|
||||
* Cursor models derived from CURSOR_MODEL_MAP
|
||||
* ID is prefixed with "cursor-" for ProviderFactory routing (if not already prefixed)
|
||||
* IDs already have 'cursor-' prefix in the canonical format
|
||||
*/
|
||||
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: id.startsWith('cursor-') ? id : `cursor-${id}`,
|
||||
id, // Already prefixed in canonical format
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
provider: 'cursor' as ModelProvider,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -70,22 +70,30 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
|
||||
return enabledCursorModels.includes(model.id as any);
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
|
||||
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
|
||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||
return (
|
||||
enabledCursorModels.includes(model.id as any) ||
|
||||
enabledCursorModels.includes(unprefixedId as any)
|
||||
);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
|
||||
onModelSelect(cursorDefaultModel);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,7 +302,7 @@ export function ModelSelector({
|
||||
{/* Loading state */}
|
||||
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
Loading models...
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
ScrollText,
|
||||
Loader2,
|
||||
Check,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
|
||||
<div className="flex items-center gap-2">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||
|
||||
@@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : filteredBranches.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
|
||||
>
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<Spinner size="md" className="mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning ? (
|
||||
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
|
||||
</div>
|
||||
) : !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
|
||||
<Spinner size="xl" className="mb-3" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Logs will appear as the server generates output
|
||||
@@ -256,7 +256,6 @@ export function DevServerLogsPanel({
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
fontSize={13}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
|
||||
@@ -26,13 +26,22 @@ import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
useAvailableTerminals,
|
||||
useEffectiveDefaultTerminal,
|
||||
} from '../hooks/use-available-terminals';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -108,6 +121,20 @@ export function WorktreeActionsDropdown({
|
||||
? getEditorIcon(effectiveDefaultEditor.command)
|
||||
: null;
|
||||
|
||||
// Get available terminals for the "Open In Terminal" submenu
|
||||
const { terminals, hasExternalTerminals } = useAvailableTerminals();
|
||||
|
||||
// Use shared hook for effective default terminal (null = integrated terminal)
|
||||
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
|
||||
|
||||
// Get the user's preferred mode for opening terminals (new tab vs split)
|
||||
const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode);
|
||||
|
||||
// Get icon component for the effective terminal
|
||||
const DefaultTerminalIcon = effectiveDefaultTerminal
|
||||
? getTerminalIcon(effectiveDefaultTerminal.id)
|
||||
: Terminal;
|
||||
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{/* Open in terminal - always show with integrated + external options */}
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens in default terminal (integrated or external) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (effectiveDefaultTerminal) {
|
||||
// External terminal is the default
|
||||
onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id);
|
||||
} else {
|
||||
// Integrated terminal is the default - use user's preferred mode
|
||||
const mode = openTerminalMode === 'newTab' ? 'tab' : 'split';
|
||||
onOpenInIntegratedTerminal(worktree, mode);
|
||||
}
|
||||
}}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with all terminals */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{/* Automaker Terminal - with submenu for new tab vs split */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<Terminal className="w-3.5 h-3.5 mr-2" />
|
||||
Terminal
|
||||
{!effectiveDefaultTerminal && (
|
||||
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SquarePlus className="w-3.5 h-3.5 mr-2" />
|
||||
New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
|
||||
Split
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{/* External terminals */}
|
||||
{terminals.length > 0 && <DropdownMenuSeparator />}
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={terminal.id}
|
||||
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
{terminal.name}
|
||||
{isDefault && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{!worktree.isMain && hasInitScript && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
|
||||
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
@@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayBranch}</span>
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
)}
|
||||
@@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
|
||||
{isRunning && <Spinner size="xs" className="shrink-0" />}
|
||||
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||
{worktree.branch}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +84,8 @@ export function WorktreeTab({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -197,8 +202,8 @@ export function WorktreeTab({
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -264,8 +269,8 @@ export function WorktreeTab({
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -342,6 +347,8 @@ export function WorktreeTab({
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { TerminalInfo } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AvailableTerminals');
|
||||
|
||||
// Re-export TerminalInfo for convenience
|
||||
export type { TerminalInfo };
|
||||
|
||||
export function useAvailableTerminals() {
|
||||
const [terminals, setTerminals] = useState<TerminalInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchAvailableTerminals = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getAvailableTerminals) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getAvailableTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available terminals:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh terminals by clearing the server cache and re-detecting
|
||||
* Use this when the user has installed/uninstalled terminals
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.refreshTerminals) {
|
||||
// Fallback to regular fetch if refresh not available
|
||||
await fetchAvailableTerminals();
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.refreshTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh terminals:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableTerminals();
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
return {
|
||||
terminals,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refresh,
|
||||
// Convenience property: has external terminals available
|
||||
hasExternalTerminals: terminals.length > 0,
|
||||
// The first terminal is the "default" one (highest priority)
|
||||
defaultTerminal: terminals[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the effective default terminal based on user settings
|
||||
* Returns null if user prefers integrated terminal (defaultTerminalId is null)
|
||||
* Falls back to: user preference > first available external terminal
|
||||
*/
|
||||
export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null {
|
||||
const defaultTerminalId = useAppStore((s) => s.defaultTerminalId);
|
||||
|
||||
return useMemo(() => {
|
||||
// If user hasn't set a preference (null/undefined), they prefer integrated terminal
|
||||
if (defaultTerminalId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user has set a preference, find it in available terminals
|
||||
if (defaultTerminalId) {
|
||||
const found = terminals.find((t) => t.id === defaultTerminalId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// If the saved preference doesn't exist anymore, fall back to first available
|
||||
return terminals[0] ?? null;
|
||||
}, [terminals, defaultTerminalId]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
@@ -35,6 +36,7 @@ interface UseWorktreeActionsOptions {
|
||||
}
|
||||
|
||||
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
|
||||
const navigate = useNavigate();
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
@@ -125,6 +127,19 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInIntegratedTerminal = useCallback(
|
||||
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
|
||||
// Navigate to the terminal view with the worktree path and branch name
|
||||
// The terminal view will handle creating the terminal with the specified cwd
|
||||
// Include nonce to allow opening the same worktree multiple times
|
||||
navigate({
|
||||
to: '/terminal',
|
||||
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
@@ -143,6 +158,27 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenInExternalTerminal = useCallback(
|
||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInExternalTerminal) {
|
||||
logger.warn('Open in external terminal API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Open in external terminal failed:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
isPushing,
|
||||
@@ -152,6 +188,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
// Re-export shared types from @automaker/types
|
||||
export type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -43,7 +39,8 @@ export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
/** PR state: OPEN, MERGED, or CLOSED */
|
||||
state: PRState;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: Array<{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user