diff --git a/CLAUDE.md b/CLAUDE.md index d46d1284..128cd8d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/README.md b/README.md index 3f9889fc..75705673 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d90c7a36..43c65992 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 { diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts index 4626ed25..1deef0db 100644 --- a/apps/server/src/lib/auth.ts +++ b/apps/server/src/lib/auth.ts @@ -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( diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts index 3f7ea60d..4742a5b0 100644 --- a/apps/server/src/lib/worktree-metadata.ts +++ b/apps/server/src/lib/worktree-metadata.ts @@ -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; diff --git a/apps/server/src/providers/cursor-config-manager.ts b/apps/server/src/providers/cursor-config-manager.ts index aa57d2b6..7b32ceb9 100644 --- a/apps/server/src/providers/cursor-config-manager.ts +++ b/apps/server/src/providers/cursor-config-manager.ts @@ -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(); diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index e4ff2c45..558065c4 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -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, diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 16dbd197..e587a061 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -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( diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts new file mode 100644 index 00000000..405a31b2 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts index 9a1b4690..a2ccd832 100644 --- a/apps/server/src/routes/auto-mode/routes/status.ts +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -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 => { 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'); diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts new file mode 100644 index 00000000..79f074a8 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts index 1fab1e2a..a1797a3f 100644 --- a/apps/server/src/routes/backlog-plan/common.ts +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -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 { diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts index 0e9218e6..cd67d3db 100644 --- a/apps/server/src/routes/backlog-plan/routes/generate.ts +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -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); diff --git a/apps/server/src/routes/fs/routes/image.ts b/apps/server/src/routes/fs/routes/image.ts index b7e8c214..32f3b3cb 100644 --- a/apps/server/src/routes/fs/routes/image.ts +++ b/apps/server/src/routes/fs/routes/image.ts @@ -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'; diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index a04227d8..b45e9965 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -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) { diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 4b54ae9e..854e5c60 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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( diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index 1bde9448..87777c69 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -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}`); diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 96782d64..f0d9c030 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -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(); 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; } } diff --git a/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 00000000..9b13101e --- /dev/null +++ b/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 0b807100..bd342dc9 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -236,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 @@ -268,12 +279,15 @@ export class AutoModeService { private runningFeatures = new Map(); private autoLoop: AutoLoopState | null = null; private featureLoader = new FeatureLoader(); + // Per-project autoloop state (supports multiple concurrent projects) + private autoLoopsByProject = new Map(); + // 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(); 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; @@ -285,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(); @@ -312,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) { @@ -342,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 = []; @@ -350,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 { + // 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 { + 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 { + 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 { + 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 { + // 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'); } @@ -397,6 +742,9 @@ export class AutoModeService { }); } + /** + * @deprecated Use runAutoLoopForProject instead + */ private async runAutoLoop(): Promise { while ( this.autoLoopRunning && @@ -449,6 +797,7 @@ export class AutoModeService { /** * Stop the auto mode loop + * @deprecated Use stopAutoLoopForProject instead for multi-project support */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; @@ -1782,6 +2131,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 */ @@ -2259,6 +2648,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); @@ -2271,8 +2664,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 []; } } diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index e63b075c..5b9f81cb 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -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, @@ -223,19 +231,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): Partial { + const migrated: Partial = { ...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 +332,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 +851,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 { + 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); + } + } + } } diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c2ea6123..8773180d 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -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"') ); }); diff --git a/apps/server/tests/unit/lib/worktree-metadata.test.ts b/apps/server/tests/unit/lib/worktree-metadata.test.ts index ab7967f3..2f84af88 100644 --- a/apps/server/tests/unit/lib/worktree-metadata.test.ts +++ b/apps/server/tests/unit/lib/worktree-metadata.test.ts @@ -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(), }, }; diff --git a/apps/server/tests/unit/providers/cursor-config-manager.test.ts b/apps/server/tests/unit/providers/cursor-config-manager.test.ts index 133daaba..11485409 100644 --- a/apps/server/tests/unit/providers/cursor-config-manager.test.ts +++ b/apps/server/tests/unit/providers/cursor-config-manager.test.ts @@ -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(); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index 3a0c6d77..70511af8 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -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', }); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index 72755463..f0053d53 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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", diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index d51e316c..fa3d5c94 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -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
- +

Loading usage data...

) : ( diff --git a/apps/ui/src/components/codex-usage-popover.tsx b/apps/ui/src/components/codex-usage-popover.tsx index f6005b6a..0fee4226 100644 --- a/apps/ui/src/components/codex-usage-popover.tsx +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -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
- +

Loading usage data...

) : codexUsage.rateLimits ? ( diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 89ab44da..e381c366 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -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 && (
- +
)} @@ -353,7 +354,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa )} > {isProcessing ? ( - + ) : ( )} diff --git a/apps/ui/src/components/dialogs/new-project-modal.tsx b/apps/ui/src/components/dialogs/new-project-modal.tsx index dd114bf9..55df0a1c 100644 --- a/apps/ui/src/components/dialogs/new-project-modal.tsx +++ b/apps/ui/src/components/dialogs/new-project-modal.tsx @@ -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 ? ( <> - + {activeTab === 'template' ? 'Cloning...' : 'Creating...'} ) : ( diff --git a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 4f287465..84e723fc 100644 --- a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -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
{isLoading && (
- +

Loading projects...

)} diff --git a/apps/ui/src/components/icons/terminal-icons.tsx b/apps/ui/src/components/icons/terminal-icons.tsx new file mode 100644 index 00000000..38e8a47d --- /dev/null +++ b/apps/ui/src/components/icons/terminal-icons.tsx @@ -0,0 +1,213 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { Terminal } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +/** + * iTerm2 logo icon + */ +export function ITerm2Icon(props: IconProps) { + return ( + + + + ); +} + +/** + * Warp terminal logo icon + */ +export function WarpIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Ghostty terminal logo icon + */ +export function GhosttyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Alacritty terminal logo icon + */ +export function AlacrittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * WezTerm terminal logo icon + */ +export function WezTermIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Kitty terminal logo icon + */ +export function KittyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Hyper terminal logo icon + */ +export function HyperIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Tabby terminal logo icon + */ +export function TabbyIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Rio terminal logo icon + */ +export function RioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windows Terminal logo icon + */ +export function WindowsTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * PowerShell logo icon + */ +export function PowerShellIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Command Prompt (cmd) logo icon + */ +export function CmdIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Git Bash logo icon + */ +export function GitBashIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * GNOME Terminal logo icon + */ +export function GnomeTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Konsole logo icon + */ +export function KonsoleIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Terminal logo icon + */ +export function MacOSTerminalIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for a terminal ID + */ +export function getTerminalIcon(terminalId: string): IconComponent { + const terminalIcons: Record = { + 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; +} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx index c1a2fa26..f98e05ac 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-switcher-item.tsx @@ -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 (
) : !codexUsage ? (
- +

Loading usage data...

) : codexUsage.rateLimits ? ( diff --git a/apps/ui/src/components/views/agent-tools-view.tsx b/apps/ui/src/components/views/agent-tools-view.tsx index 4485f165..48c3f92d 100644 --- a/apps/ui/src/components/views/agent-tools-view.tsx +++ b/apps/ui/src/components/views/agent-tools-view.tsx @@ -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 ? ( <> - + Reading... ) : ( @@ -315,7 +315,7 @@ export function AgentToolsView() { > {isWritingFile ? ( <> - + Writing... ) : ( @@ -383,7 +383,7 @@ export function AgentToolsView() { > {isRunningCommand ? ( <> - + Running... ) : ( diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 5d877471..1278601c 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -42,7 +42,7 @@ export function AgentView() { return () => window.removeEventListener('resize', updateVisibility); }, []); - const [modelSelection, setModelSelection] = useState({ model: 'sonnet' }); + const [modelSelection, setModelSelection] = useState({ model: 'claude-sonnet' }); // Input ref for auto-focus const inputRef = useRef(null); diff --git a/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx b/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx index facd4fc5..ff2965d5 100644 --- a/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx +++ b/apps/ui/src/components/views/agent-view/components/thinking-indicator.tsx @@ -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() {
-
- - - -
+ Thinking...
diff --git a/apps/ui/src/components/views/analysis-view.tsx b/apps/ui/src/components/views/analysis-view.tsx index e235a9e9..2143d390 100644 --- a/apps/ui/src/components/views/analysis-view.tsx +++ b/apps/ui/src/components/views/analysis-view.tsx @@ -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)
{children}
diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index d871ab30..33bd624a 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -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, diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 957fccc0..79a8c227 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -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 && (
- + Loading models...
)} diff --git a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx index 66af8d13..5c9bb5db 100644 --- a/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/planning-mode-selector.tsx @@ -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({
{isGenerating ? ( <> - + Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}... diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx index c7e7b7ef..0f6d2af3 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/branch-switch-dropdown.tsx @@ -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({
{isLoadingBranches ? ( - + Loading branches... ) : filteredBranches.length === 0 ? ( diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index 859ad34c..02dcdb29 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -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" > - + {isLoading ? : }
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({ > {isLoading && !logs ? (
- + Loading logs...
) : !logs && !isRunning ? ( @@ -245,7 +245,7 @@ export function DevServerLogsPanel({ ) : !logs ? (
-
+

Waiting for output...

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)} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 459e2ce8..41041315 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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({ )} + {/* Open in terminal - always show with integrated + external options */} + +

+ {/* Main clickable area - opens in default terminal (integrated or external) */} + { + 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" + > + + Open in {effectiveDefaultTerminal?.name ?? 'Terminal'} + + {/* Chevron trigger for submenu with all terminals */} + +
+ + {/* Automaker Terminal - with submenu for new tab vs split */} + + + + Terminal + {!effectiveDefaultTerminal && ( + (default) + )} + + + onOpenInIntegratedTerminal(worktree, 'tab')} + className="text-xs" + > + + New Tab + + onOpenInIntegratedTerminal(worktree, 'split')} + className="text-xs" + > + + Split + + + + {/* External terminals */} + {terminals.length > 0 && } + {terminals.map((terminal) => { + const TerminalIcon = getTerminalIcon(terminal.id); + const isDefault = terminal.id === effectiveDefaultTerminal?.id; + return ( + onOpenInExternalTerminal(worktree, terminal.id)} + className="text-xs" + > + + {terminal.name} + {isDefault && ( + (default) + )} + + ); + })} + + {!worktree.isMain && hasInitScript && ( onRunInitScript(worktree)} className="text-xs"> diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx index 52a07c96..079c9b11 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx @@ -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({ {displayBranch} {isActivating ? ( - + ) : ( )} @@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({ ) : (
)} - {isRunning && } + {isRunning && } {worktree.branch} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 5cb379d3..56478385 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -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 && } - {isActivating && !isRunning && } + {isRunning && } + {isActivating && !isRunning && } {worktree.branch} {cardCount !== undefined && cardCount > 0 && ( @@ -264,8 +269,8 @@ export function WorktreeTab({ : 'Click to switch to this branch' } > - {isRunning && } - {isActivating && !isRunning && } + {isRunning && } + {isActivating && !isRunning && } {worktree.branch} {cardCount !== undefined && cardCount > 0 && ( @@ -342,6 +347,8 @@ export function WorktreeTab({ onPull={onPull} onPush={onPush} onOpenInEditor={onOpenInEditor} + onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} + onOpenInExternalTerminal={onOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts new file mode 100644 index 00000000..b719183d --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-terminals.ts @@ -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([]); + 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]); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dc..8e7f6e4e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/types.ts b/apps/ui/src/components/views/board-view/worktree-panel/types.ts index d2040048..a9cdedca 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/types.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/types.ts @@ -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<{ diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4..1c05eb7b 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { Button } from '@/components/ui/button'; import { GitBranch, Plus, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; @@ -78,7 +79,9 @@ export function WorktreePanel({ handleSwitchBranch, handlePull, handlePush, + handleOpenInIntegratedTerminal, handleOpenInEditor, + handleOpenInExternalTerminal, } = useWorktreeActions({ fetchWorktrees, fetchBranches, @@ -245,6 +248,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -285,7 +290,7 @@ export function WorktreePanel({ disabled={isLoading} title="Refresh worktrees" > - + {isLoading ? : } )} @@ -332,6 +337,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -390,6 +397,8 @@ export function WorktreePanel({ onPull={handlePull} onPush={handlePush} onOpenInEditor={handleOpenInEditor} + onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} + onOpenInExternalTerminal={handleOpenInExternalTerminal} onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} @@ -429,7 +438,7 @@ export function WorktreePanel({ disabled={isLoading} title="Refresh worktrees" > - + {isLoading ? : }
diff --git a/apps/ui/src/components/views/code-view.tsx b/apps/ui/src/components/views/code-view.tsx index 581a298b..ce80bc23 100644 --- a/apps/ui/src/components/views/code-view.tsx +++ b/apps/ui/src/components/views/code-view.tsx @@ -4,7 +4,8 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react'; +import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; const logger = createLogger('CodeView'); @@ -206,7 +207,7 @@ export function CodeView() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 024ee392..b186e0c1 100644 --- a/apps/ui/src/components/views/context-view.tsx +++ b/apps/ui/src/components/views/context-view.tsx @@ -12,7 +12,6 @@ import { HeaderActionsPanelTrigger, } from '@/components/ui/header-actions-panel'; import { - RefreshCw, FileText, Image as ImageIcon, Trash2, @@ -24,9 +23,9 @@ import { Pencil, FilePlus, FileUp, - Loader2, MoreVertical, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, @@ -670,7 +669,7 @@ export function ContextView() { if (isLoading) { return (
- +
); } @@ -790,7 +789,7 @@ export function ContextView() { {isUploading && (
- + Uploading {uploadingFileName}...
@@ -838,7 +837,7 @@ export function ContextView() { {file.name} {isGenerating ? ( - + Generating description... ) : file.description ? ( @@ -955,7 +954,7 @@ export function ContextView() { {generatingDescriptions.has(selectedFile.name) ? (
- + Generating description with AI...
) : selectedFile.description ? ( diff --git a/apps/ui/src/components/views/dashboard-view.tsx b/apps/ui/src/components/views/dashboard-view.tsx index 7e657c80..872b97a8 100644 --- a/apps/ui/src/components/views/dashboard-view.tsx +++ b/apps/ui/src/components/views/dashboard-view.tsx @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { useNavigate } from '@tanstack/react-router'; -import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { useAppStore } from '@/store/app-store'; import { useOSDetection } from '@/hooks/use-os-detection'; import { getElectronAPI, isElectron } from '@/lib/electron'; import { initializeProject } from '@/lib/project-init'; @@ -18,7 +18,6 @@ import { Folder, Star, Clock, - Loader2, ChevronDown, MessageSquare, MoreVertical, @@ -28,6 +27,7 @@ import { type LucideIcon, } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Input } from '@/components/ui/input'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { @@ -76,14 +76,11 @@ export function DashboardView() { const { projects, - trashedProjects, - currentProject, upsertAndSetCurrentProject, addProject, setCurrentProject, toggleProjectFavorite, moveProjectToTrash, - theme: globalTheme, } = useAppStore(); const [showNewProjectModal, setShowNewProjectModal] = useState(false); @@ -124,18 +121,27 @@ export function DashboardView() { const initResult = await initializeProject(path); if (!initResult.success) { + // If the project directory doesn't exist, automatically remove it from the project list + if (initResult.error?.includes('does not exist')) { + const projectToRemove = projects.find((p) => p.path === path); + if (projectToRemove) { + logger.warn(`[Dashboard] Removing project with non-existent path: ${path}`); + moveProjectToTrash(projectToRemove.id); + toast.error('Project directory not found', { + description: `Removed ${name} from your projects list since the directory no longer exists.`, + }); + return; + } + } + toast.error('Failed to initialize project', { description: initResult.error || 'Unknown error occurred', }); return; } - 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); toast.success('Project opened', { description: `Opened ${name}`, @@ -151,7 +157,7 @@ export function DashboardView() { setIsOpening(false); } }, - [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate] + [projects, upsertAndSetCurrentProject, navigate, moveProjectToTrash] ); const handleOpenProject = useCallback(async () => { @@ -992,7 +998,7 @@ export function DashboardView() { data-testid="project-opening-overlay" >
- +

Opening project...

diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx index 3ff836dc..cc62a7fe 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -4,7 +4,6 @@ import { X, Wand2, ExternalLink, - Loader2, CheckCircle, Clock, GitPullRequest, @@ -14,6 +13,7 @@ import { ChevronDown, ChevronUp, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -87,7 +87,7 @@ export function IssueDetailPanel({ if (isValidating) { return ( ); @@ -297,9 +297,7 @@ export function IssueDetailPanel({ Comments {totalCount > 0 && `(${totalCount})`} - {commentsLoading && ( - - )} + {commentsLoading && } {commentsExpanded ? ( ) : ( @@ -340,7 +338,7 @@ export function IssueDetailPanel({ > {loadingMore ? ( <> - + Loading... ) : ( diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx index bf6496f1..01bf8316 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -2,12 +2,12 @@ import { Circle, CheckCircle2, ExternalLink, - Loader2, CheckCircle, Sparkles, GitPullRequest, User, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { IssueRowProps } from '../types'; @@ -97,7 +97,7 @@ export function IssueRow({ {/* Validating indicator */} {isValidating && ( - + Analyzing... )} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 1c58bbe4..5b599c4e 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,5 +1,6 @@ import { CircleDot, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { IssuesStateFilter } from '../types'; import { IssuesFilterControls } from './issues-filter-controls'; @@ -77,7 +78,7 @@ export function IssuesListHeader({
diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx index 855d136c..fbbcb9eb 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; +import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI, GitHubPR } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -86,7 +87,7 @@ export function GitHubPRsView() { if (loading) { return (
- +
); } @@ -134,7 +135,7 @@ export function GitHubPRsView() { diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx index f8e9ba0a..47acf313 100644 --- a/apps/ui/src/components/views/graph-view-page.tsx +++ b/apps/ui/src/components/views/graph-view-page.tsx @@ -17,7 +17,7 @@ import { import { useWorktrees } from './board-view/worktree-panel/hooks'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { pathsEqual } from '@/lib/utils'; -import { RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import { toast } from 'sonner'; @@ -330,7 +330,7 @@ export function GraphViewPage() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx b/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx index 41d12a34..8bf6d7bb 100644 --- a/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx +++ b/apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx @@ -4,7 +4,8 @@ */ import { useState, useMemo, useEffect, useCallback } from 'react'; -import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react'; +import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -109,7 +110,7 @@ function SuggestionCard({ )} > {isAdding ? ( - + ) : ( <> @@ -153,11 +154,7 @@ function GeneratingCard({ job }: { job: GenerationJob }) { isError ? 'bg-destructive/10 text-destructive' : 'bg-blue-500/10 text-blue-500' )} > - {isError ? ( - - ) : ( - - )} + {isError ? : }

{job.prompt.title}

diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx index a4d3d505..c09548b0 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx @@ -13,8 +13,8 @@ import { Gauge, Accessibility, BarChart3, - Loader2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import type { IdeaCategory } from '@automaker/types'; @@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps {isLoading && (
- + Loading categories...
)} diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx index a7e3fc8b..af52030b 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -3,7 +3,8 @@ */ import { useState, useMemo } from 'react'; -import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react'; +import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Card, CardContent } from '@/components/ui/card'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { useIdeationStore } from '@/store/ideation-store'; @@ -121,7 +122,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
{isLoadingPrompts && (
- + Loading prompts...
)} @@ -162,7 +163,7 @@ export function PromptList({ category, onBack }: PromptListProps) { }`} > {isLoading || isGenerating ? ( - + ) : isStarted ? ( ) : ( diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index 0662c6ed..50cbd8d3 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -11,7 +11,8 @@ import { PromptList } from './components/prompt-list'; import { IdeationDashboard } from './components/ideation-dashboard'; import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2, Trash2 } from 'lucide-react'; +import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { IdeaCategory } from '@automaker/types'; import type { IdeationMode } from '@/store/ideation-store'; @@ -152,11 +153,7 @@ function IdeationHeader({ className="gap-2" disabled={isAcceptingAll} > - {isAcceptingAll ? ( - - ) : ( - - )} + {isAcceptingAll ? : } Accept All ({acceptAllCount}) )} diff --git a/apps/ui/src/components/views/interview-view.tsx b/apps/ui/src/components/views/interview-view.tsx index b9b9997e..b56971c1 100644 --- a/apps/ui/src/components/views/interview-view.tsx +++ b/apps/ui/src/components/views/interview-view.tsx @@ -5,7 +5,8 @@ import { useAppStore, Feature } from '@/store/app-store'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react'; +import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn, generateUUID } from '@/lib/utils'; import { getElectronAPI } from '@/lib/electron'; import { Markdown } from '@/components/ui/markdown'; @@ -491,7 +492,7 @@ export function InterviewView() {
- + Generating specification...
@@ -571,7 +572,7 @@ export function InterviewView() { > {isGenerating ? ( <> - + Creating... ) : ( diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index faca109c..0ed259bf 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -24,7 +24,8 @@ import { } from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react'; +import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useAuthStore } from '@/store/auth-store'; import { useSetupStore } from '@/store/setup-store'; @@ -349,7 +350,7 @@ export function LoginView() { return (
- +

Connecting to server {state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'} @@ -385,7 +386,7 @@ export function LoginView() { return (

- +

{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}

@@ -447,7 +448,7 @@ export function LoginView() { > {isLoggingIn ? ( <> - + Authenticating... ) : ( diff --git a/apps/ui/src/components/views/memory-view.tsx b/apps/ui/src/components/views/memory-view.tsx index 66533413..b6331602 100644 --- a/apps/ui/src/components/views/memory-view.tsx +++ b/apps/ui/src/components/views/memory-view.tsx @@ -19,6 +19,7 @@ import { FilePlus, MoreVertical, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Dialog, DialogContent, @@ -299,7 +300,7 @@ export function MemoryView() { if (isLoading) { return (
- +
); } diff --git a/apps/ui/src/components/views/notifications-view.tsx b/apps/ui/src/components/views/notifications-view.tsx index aaffb011..08386c55 100644 --- a/apps/ui/src/components/views/notifications-view.tsx +++ b/apps/ui/src/components/views/notifications-view.tsx @@ -9,7 +9,8 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific import { getHttpApiClient } from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useNavigate } from '@tanstack/react-router'; import type { Notification } from '@automaker/types'; @@ -146,7 +147,7 @@ export function NotificationsView() { if (isLoading) { return (
- +

Loading notifications...

); diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx index c289d382..d6d0c247 100644 --- a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -10,9 +10,9 @@ import { Save, RotateCcw, Trash2, - Loader2, PanelBottomClose, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch'; import { toast } from 'sonner'; @@ -409,7 +409,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti {isLoading ? (
- +
) : ( <> @@ -448,11 +448,7 @@ npm install disabled={!scriptExists || isSaving || isDeleting} className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10" > - {isDeleting ? ( - - ) : ( - - )} + {isDeleting ? : } Delete
diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index d46729c1..b77518d0 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; -import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react'; +import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI, RunningAgent } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; @@ -146,7 +147,7 @@ export function RunningAgentsView() { if (loading) { return (
- +
); } @@ -169,7 +170,11 @@ export function RunningAgentsView() {
diff --git a/apps/ui/src/components/views/settings-view/account/account-section.tsx b/apps/ui/src/components/views/settings-view/account/account-section.tsx index 901e5040..d10049fc 100644 --- a/apps/ui/src/components/views/settings-view/account/account-section.tsx +++ b/apps/ui/src/components/views/settings-view/account/account-section.tsx @@ -11,6 +11,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { LogOut, User, Code2, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { logout } from '@/lib/http-api-client'; import { useAuthStore } from '@/store/auth-store'; @@ -143,7 +144,7 @@ export function AccountSection() { disabled={isRefreshing || isLoadingEditors} className="shrink-0 h-9 w-9" > - + {isRefreshing ? : } diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx index 6d044f6c..61b49a1c 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-key-field.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react'; +import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { ProviderConfig } from '@/config/api-providers'; interface ApiKeyFieldProps { @@ -70,7 +71,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) { > {testButton.loading ? ( <> - + Testing... ) : ( diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index 088f3ddf..840c8e63 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -1,7 +1,8 @@ import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { Button } from '@/components/ui/button'; -import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react'; +import { Key, CheckCircle2, Trash2 } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { ApiKeyField } from './api-key-field'; import { buildProviderConfigs } from '@/config/api-providers'; import { SecurityNotice } from './security-notice'; @@ -142,7 +143,7 @@ export function ApiKeysSection() { data-testid="delete-anthropic-key" > {isDeletingAnthropicKey ? ( - + ) : ( )} @@ -159,7 +160,7 @@ export function ApiKeysSection() { data-testid="delete-openai-key" > {isDeletingOpenaiKey ? ( - + ) : ( )} diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 11912ec4..2aa1ff3c 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -4,6 +4,7 @@ import { getElectronAPI } from '@/lib/electron'; import { useSetupStore } from '@/store/setup-store'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { RefreshCw, AlertCircle } from 'lucide-react'; const ERROR_NO_API = 'Claude usage API not available'; @@ -178,7 +179,7 @@ export function ClaudeUsageSection() { data-testid="refresh-claude-usage" title={CLAUDE_REFRESH_LABEL} > - + {isLoading ? : }

{CLAUDE_USAGE_SUBTITLE}

diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index 2457969b..a6474a7a 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -172,7 +173,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx index dd194c1f..6e577787 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -56,7 +57,7 @@ export function CliStatusCard({ 'transition-all duration-200' )} > - + {isChecking ? : }

{description}

diff --git a/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx index 86635264..3e0d8b53 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -165,7 +166,7 @@ export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: Cl 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx index bc49270c..68c052fb 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import { CursorIcon } from '@/components/ui/provider-icon'; @@ -290,7 +291,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx index bfd9efe6..7d7577c5 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Spinner } from '@/components/ui/spinner'; import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; @@ -221,7 +222,7 @@ export function OpencodeCliStatus({ 'transition-all duration-200' )} > - + {isChecking ? : }

diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx index b879df4a..9012047d 100644 --- a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { useCallback, useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { RefreshCw, AlertCircle } from 'lucide-react'; import { OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; @@ -168,7 +169,7 @@ export function CodexUsageSection() { data-testid="refresh-codex-usage" title={CODEX_REFRESH_LABEL} > - + {isLoading ? : }

{CODEX_USAGE_SUBTITLE}

diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx index 780f5f98..e9c5a071 100644 --- a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { History, @@ -184,7 +185,11 @@ export function EventHistoryView() {

{events.length > 0 && ( diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx index babf4bda..752b06e7 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx @@ -1,4 +1,5 @@ -import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -111,7 +112,7 @@ export function MCPServerCard({ className="h-8 px-2" > {testState?.status === 'testing' ? ( - + ) : ( )} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx index a85fc305..8caf3bca 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx @@ -1,5 +1,6 @@ import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; interface MCPServerHeaderProps { @@ -43,7 +44,7 @@ export function MCPServerHeader({ disabled={isRefreshing} data-testid="refresh-mcp-servers-button" > - + {isRefreshing ? : } {hasServers && ( <> diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx index 25102025..83687556 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/utils.tsx @@ -1,4 +1,5 @@ -import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { Terminal, Globe, CheckCircle2, XCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import type { ServerType, ServerTestState } from './types'; import { SENSITIVE_PARAM_PATTERNS } from './constants'; @@ -40,7 +41,7 @@ export function getServerIcon(type: ServerType = 'stdio') { export function getTestStatusIcon(status: ServerTestState['status']) { switch (status) { case 'testing': - return ; + return ; case 'success': return ; case 'error': diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 392445e0..69392afa 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -279,8 +279,8 @@ export function PhaseModelSelector({ }, [codexModels]); // Filter Cursor models to only show enabled ones + // With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format const availableCursorModels = 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 CursorModelId); }); @@ -300,6 +300,7 @@ export function PhaseModelSelector({ }; } + // With canonical IDs, direct comparison works const cursorModel = availableCursorModels.find((m) => m.id === selectedModel); if (cursorModel) return { ...cursorModel, icon: CursorIcon }; @@ -352,7 +353,7 @@ export function PhaseModelSelector({ const seenGroups = new Set(); availableCursorModels.forEach((model) => { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; + const cursorId = model.id as CursorModelId; // Check if this model is standalone if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { @@ -908,8 +909,8 @@ export function PhaseModelSelector({ // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { - const modelValue = stripProviderPrefix(model.id); - const isSelected = selectedModel === modelValue; + // With canonical IDs, store the full prefixed ID + const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); return ( @@ -917,7 +918,7 @@ export function PhaseModelSelector({ key={model.id} value={model.label} onSelect={() => { - onChange({ model: modelValue as CursorModelId }); + onChange({ model: model.id as CursorModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" @@ -1458,7 +1459,7 @@ export function PhaseModelSelector({ return favorites.map((model) => { // Check if this favorite is part of a grouped model if (model.provider === 'cursor') { - const cursorId = stripProviderPrefix(model.id) as CursorModelId; + const cursorId = model.id as CursorModelId; const group = getModelGroup(cursorId); if (group) { // Skip if we already rendered this group diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx index 08800331..d1f1bf76 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/subagents-section.tsx @@ -14,16 +14,8 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; -import { - Bot, - RefreshCw, - Loader2, - Users, - ExternalLink, - Globe, - FolderOpen, - Sparkles, -} from 'lucide-react'; +import { Bot, RefreshCw, Users, ExternalLink, Globe, FolderOpen, Sparkles } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { useSubagents } from './hooks/use-subagents'; import { useSubagentsSettings } from './hooks/use-subagents-settings'; import { SubagentCard } from './subagent-card'; @@ -178,11 +170,7 @@ export function SubagentsSection() { title="Refresh agents from disk" className="gap-1.5 h-7 px-2 text-xs" > - {isLoadingAgents ? ( - - ) : ( - - )} + {isLoadingAgents ? : } Refresh
diff --git a/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx index 99a27be4..6e3f7097 100644 --- a/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/cursor-model-configuration.tsx @@ -92,7 +92,8 @@ export function CursorModelConfiguration({
{availableModels.map((model) => { const isEnabled = enabledCursorModels.includes(model.id); - const isAuto = model.id === 'auto'; + // With canonical IDs, 'auto' becomes 'cursor-auto' + const isAuto = model.id === 'cursor-auto'; return (
-
+
) : ( <> diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx index 3d2d0fb6..6ecce79c 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -9,7 +9,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react'; +import { Terminal, Cloud, Cpu, Brain, Github, KeyRound, ShieldCheck } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import type { @@ -500,7 +501,7 @@ export function OpencodeModelConfiguration({

{isLoadingDynamicModels && (
- + Discovering...
)} diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx index f1cebb10..eb81e847 100644 --- a/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-section.tsx @@ -2,6 +2,7 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Slider } from '@/components/ui/slider'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -9,12 +10,20 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { SquareTerminal } from 'lucide-react'; +import { + SquareTerminal, + RefreshCw, + Terminal, + SquarePlus, + SplitSquareHorizontal, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { toast } from 'sonner'; import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes'; import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; +import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals'; +import { getTerminalIcon } from '@/components/icons/terminal-icons'; export function TerminalSection() { const { @@ -25,6 +34,9 @@ export function TerminalSection() { setTerminalScrollbackLines, setTerminalLineHeight, setTerminalDefaultFontSize, + defaultTerminalId, + setDefaultTerminalId, + setOpenTerminalMode, } = useAppStore(); const { @@ -34,8 +46,12 @@ export function TerminalSection() { scrollbackLines, lineHeight, defaultFontSize, + openTerminalMode, } = terminalState; + // Get available external terminals + const { terminals, isRefreshing, refresh } = useAvailableTerminals(); + return (
+ {/* Default External Terminal */} +
+
+ + +
+

+ Terminal to use when selecting "Open in Terminal" from the worktree menu +

+ + {terminals.length === 0 && !isRefreshing && ( +

+ No external terminals detected. Click refresh to re-scan. +

+ )} +
+ + {/* Default Open Mode */} +
+ +

+ How to open the integrated terminal when using "Open in Terminal" from the worktree menu +

+ +
+ {/* Font Family */}
diff --git a/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx b/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx index ee32f231..4932ef29 100644 --- a/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx +++ b/apps/ui/src/components/views/setup-view/components/cli-installation-card.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Download, Loader2, AlertCircle } from 'lucide-react'; +import { Download, AlertCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { CopyableCommandField } from './copyable-command-field'; import { TerminalOutput } from './terminal-output'; @@ -59,7 +60,7 @@ export function CliInstallationCard({ > {isInstalling ? ( <> - + Installing... ) : ( diff --git a/apps/ui/src/components/views/setup-view/components/status-badge.tsx b/apps/ui/src/components/views/setup-view/components/status-badge.tsx index 38692a0b..53869d07 100644 --- a/apps/ui/src/components/views/setup-view/components/status-badge.tsx +++ b/apps/ui/src/components/views/setup-view/components/status-badge.tsx @@ -1,4 +1,5 @@ -import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react'; +import { CheckCircle2, XCircle, AlertCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; interface StatusBadgeProps { status: @@ -34,7 +35,7 @@ export function StatusBadge({ status, label }: StatusBadgeProps) { }; case 'checking': return { - icon: , + icon: , className: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', }; case 'unverified': diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index 8b56f49c..87bf6f77 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -14,7 +14,6 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, Key, ArrowRight, ArrowLeft, @@ -27,6 +26,7 @@ import { XCircle, Trash2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge, TerminalOutput } from '../components'; import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks'; @@ -330,7 +330,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps Authentication Methods
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {isInstalling ? ( <> - + Installing... ) : ( @@ -435,7 +435,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps {/* CLI Verification Status */} {cliVerificationStatus === 'verifying' && (
- +

Verifying CLI authentication...

Running a test query

@@ -494,7 +494,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {cliVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : cliVerificationStatus === 'error' ? ( @@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {isSavingApiKey ? ( <> - + Saving... ) : ( @@ -589,11 +589,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400" data-testid="delete-anthropic-key-button" > - {isDeletingApiKey ? ( - - ) : ( - - )} + {isDeletingApiKey ? : } )}
@@ -602,7 +598,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps {/* API Key Verification Status */} {apiKeyVerificationStatus === 'verifying' && (
- +

Verifying API key...

Running a test query

@@ -642,7 +638,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps > {apiKeyVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : apiKeyVerificationStatus === 'error' ? ( diff --git a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx index cf581f8c..031d6815 100644 --- a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx @@ -14,7 +14,6 @@ import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, Key, ArrowRight, ArrowLeft, @@ -27,6 +26,7 @@ import { XCircle, Trash2, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge, TerminalOutput } from '../components'; import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks'; @@ -332,7 +332,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup Authentication Methods
Choose one of the following methods to authenticate: @@ -408,7 +408,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {isInstalling ? ( <> - + Installing... ) : ( @@ -427,7 +427,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup {cliVerificationStatus === 'verifying' && (
- +

Verifying CLI authentication...

Running a test query

@@ -605,7 +605,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {cliVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : cliVerificationStatus === 'error' ? ( @@ -681,7 +681,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {isSavingApiKey ? ( <> - + Saving... ) : ( @@ -696,11 +696,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400" data-testid={config.testIds.deleteApiKeyButton} > - {isDeletingApiKey ? ( - - ) : ( - - )} + {isDeletingApiKey ? : } )}
@@ -708,7 +704,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup {apiKeyVerificationStatus === 'verifying' && (
- +

Verifying API key...

Running a test query

@@ -767,7 +763,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup > {apiKeyVerificationStatus === 'verifying' ? ( <> - + Verifying... ) : apiKeyVerificationStatus === 'error' ? ( diff --git a/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx index ff591f1a..e48057c4 100644 --- a/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cursor-setup-step.tsx @@ -7,7 +7,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -16,6 +15,7 @@ import { AlertTriangle, XCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; import { CursorIcon } from '@/components/ui/provider-icon'; @@ -204,7 +204,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps
{getStatusBadge()}
@@ -318,7 +318,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -332,7 +332,7 @@ export function CursorSetupStep({ onNext, onBack, onSkip }: CursorSetupStepProps {/* Loading State */} {isChecking && (
- +

Checking Cursor CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx index fcccb618..3a20ee24 100644 --- a/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/github-setup-step.tsx @@ -6,7 +6,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -16,6 +15,7 @@ import { Github, XCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; @@ -116,7 +116,7 @@ export function GitHubSetupStep({ onNext, onBack, onSkip }: GitHubSetupStepProps
{getStatusBadge()}
@@ -252,7 +252,7 @@ export function GitHubSetupStep({ onNext, onBack, onSkip }: GitHubSetupStepProps {/* Loading State */} {isChecking && (
- +

Checking GitHub CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx index 5e7e29c0..58337851 100644 --- a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx @@ -7,7 +7,6 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { CheckCircle2, - Loader2, ArrowRight, ArrowLeft, ExternalLink, @@ -17,6 +16,7 @@ import { XCircle, Terminal, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { StatusBadge } from '../components'; @@ -204,7 +204,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
{getStatusBadge()}
@@ -316,7 +316,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -330,7 +330,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP {/* Loading State */} {isChecking && (
- +

Checking OpenCode CLI status...

diff --git a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx index b9ad3263..53b3ca0b 100644 --- a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -17,7 +17,6 @@ import { ArrowRight, ArrowLeft, CheckCircle2, - Loader2, Key, ExternalLink, Copy, @@ -29,6 +28,7 @@ import { Terminal, AlertCircle, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; @@ -240,7 +240,7 @@ function ClaudeContent() { onClick={checkStatus} disabled={isChecking || isVerifying} > - + {isChecking || isVerifying ? : }
@@ -278,7 +278,7 @@ function ClaudeContent() { {/* Checking/Verifying State */} {(isChecking || isVerifying) && (
- +

{isChecking ? 'Checking Claude CLI status...' : 'Verifying authentication...'}

@@ -322,7 +322,7 @@ function ClaudeContent() { > {isInstalling ? ( <> - + Installing... ) : ( @@ -417,11 +417,7 @@ function ClaudeContent() { disabled={isSavingApiKey || !apiKey.trim()} className="flex-1 bg-brand-500 hover:bg-brand-600 text-white" > - {isSavingApiKey ? ( - - ) : ( - 'Save API Key' - )} + {isSavingApiKey ? : 'Save API Key'} {hasApiKey && (
@@ -658,7 +654,7 @@ function CursorContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -671,7 +667,7 @@ function CursorContent() { {isChecking && (
- +

Checking Cursor CLI status...

)} @@ -807,7 +803,7 @@ function CodexContent() { Codex CLI Status
@@ -915,7 +911,7 @@ function CodexContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -958,7 +954,7 @@ function CodexContent() { disabled={isSaving || !apiKey.trim()} className="w-full bg-brand-500 hover:bg-brand-600 text-white" > - {isSaving ? : 'Save API Key'} + {isSaving ? : 'Save API Key'} @@ -968,7 +964,7 @@ function CodexContent() { {isChecking && (
- +

Checking Codex CLI status...

)} @@ -1082,7 +1078,7 @@ function OpencodeContent() { OpenCode CLI Status
@@ -1191,7 +1187,7 @@ function OpencodeContent() { > {isLoggingIn ? ( <> - + Waiting for login... ) : ( @@ -1204,7 +1200,7 @@ function OpencodeContent() { {isChecking && (
- +

Checking OpenCode CLI status...

)} @@ -1416,7 +1412,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) ); case 'verifying': return ( - + ); case 'installed_not_auth': return ( @@ -1436,7 +1432,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps) {isInitialChecking && (
- +

Checking provider status...

)} diff --git a/apps/ui/src/components/views/setup-view/steps/theme-step.tsx b/apps/ui/src/components/views/setup-view/steps/theme-step.tsx index 2698ca7c..36d999f5 100644 --- a/apps/ui/src/components/views/setup-view/steps/theme-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/theme-step.tsx @@ -24,10 +24,10 @@ export function ThemeStep({ onNext, onBack }: ThemeStepProps) { const handleThemeClick = (themeValue: string) => { setTheme(themeValue as typeof theme); - // Also update the current project's theme if one exists - // This ensures the selected theme is visible since getEffectiveTheme() prioritizes project theme - if (currentProject) { - setProjectTheme(currentProject.id, themeValue as typeof theme); + // Clear the current project's theme so it uses the global theme + // This ensures "Use Global Theme" is checked and the project inherits the global theme + if (currentProject && currentProject.theme !== undefined) { + setProjectTheme(currentProject.id, null); } setPreviewTheme(null); }; diff --git a/apps/ui/src/components/views/spec-view.tsx b/apps/ui/src/components/views/spec-view.tsx index 616dc4dd..29aeb7cd 100644 --- a/apps/ui/src/components/views/spec-view.tsx +++ b/apps/ui/src/components/views/spec-view.tsx @@ -1,19 +1,32 @@ -import { useState } from 'react'; -import { RefreshCw } from 'lucide-react'; +import { useState, useCallback } from 'react'; import { useAppStore } from '@/store/app-store'; +import { Spinner } from '@/components/ui/spinner'; // Extracted hooks -import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks'; +import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks'; // Extracted components -import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components'; +import { + SpecHeader, + SpecEditor, + SpecEmptyState, + SpecViewMode, + SpecEditMode, + SpecModeTabs, +} from './spec-view/components'; // Extracted dialogs import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs'; +// Types +import type { SpecViewMode as SpecViewModeType } from './spec-view/types'; + export function SpecView() { const { currentProject, appSpec } = useAppStore(); + // View mode state - default to 'view' + const [mode, setMode] = useState('view'); + // Actions panel state (for tablet/mobile) const [showActionsPanel, setShowActionsPanel] = useState(false); @@ -21,7 +34,10 @@ export function SpecView() { const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading(); // Save state - const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave(); + const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave(); + + // Parse the spec XML + const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec); // Generation state and handlers const { @@ -70,8 +86,17 @@ export function SpecView() { handleSync, } = useSpecGeneration({ loadSpec }); - // Reset hasChanges when spec is reloaded - // (This is needed because loadSpec updates appSpec in the store) + // Handle mode change - if parse is invalid, force source mode + const handleModeChange = useCallback( + (newMode: SpecViewModeType) => { + if ((newMode === 'view' || newMode === 'edit') && !isParseValid) { + // Can't switch to view/edit if parse is invalid + return; + } + setMode(newMode); + }, + [isParseValid] + ); // No project selected if (!currentProject) { @@ -86,7 +111,7 @@ export function SpecView() { if (isLoading) { return (
- +
); } @@ -126,6 +151,28 @@ export function SpecView() { ); } + // Render content based on mode + const renderContent = () => { + // If the XML is invalid or spec is not parsed, we can only show the source editor. + // The tabs for other modes are disabled, but this is an extra safeguard. + if (!isParseValid || !parsedSpec) { + return ; + } + + switch (mode) { + case 'view': + return ; + case 'edit': + return ; + case 'source': + default: + return ; + } + }; + + const isProcessing = + isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing; + // Main view - spec exists return (
@@ -145,9 +192,33 @@ export function SpecView() { onSaveClick={saveSpec} showActionsPanel={showActionsPanel} onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)} + showSaveButton={mode !== 'view'} /> - + {/* Mode tabs and content container */} +
+ {/* Mode tabs bar - inside the content area, centered */} + {!isProcessing && ( +
+ + {/* Show parse error indicator - positioned to the right */} + {!isParseValid && parseErrors.length > 0 && ( + + XML has errors - fix in Source mode + + )} +
+ )} + + {/* Show parse error banner if in source mode with errors */} + {!isParseValid && parseErrors.length > 0 && mode === 'source' && ( +
+ XML Parse Errors: {parseErrors.join(', ')} +
+ )} + + {renderContent()} +
void; + placeholder?: string; + addLabel?: string; + emptyMessage?: string; +} + +interface ItemWithId { + id: string; + value: string; +} + +function generateId(): string { + return crypto.randomUUID(); +} + +export function ArrayFieldEditor({ + values, + onChange, + placeholder = 'Enter value...', + addLabel = 'Add Item', + emptyMessage = 'No items added yet.', +}: ArrayFieldEditorProps) { + // Track items with stable IDs + const [items, setItems] = useState(() => + values.map((value) => ({ id: generateId(), value })) + ); + + // Track if we're making an internal change to avoid sync loops + const isInternalChange = useRef(false); + + // Sync external values to internal items when values change externally + useEffect(() => { + if (isInternalChange.current) { + isInternalChange.current = false; + return; + } + + // External change - rebuild items with new IDs + setItems(values.map((value) => ({ id: generateId(), value }))); + }, [values]); + + const handleAdd = () => { + const newItems = [...items, { id: generateId(), value: '' }]; + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + const handleRemove = (id: string) => { + const newItems = items.filter((item) => item.id !== id); + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + const handleChange = (id: string, value: string) => { + const newItems = items.map((item) => (item.id === id ? { ...item, value } : item)); + setItems(newItems); + isInternalChange.current = true; + onChange(newItems.map((item) => item.value)); + }; + + return ( +
+ {items.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {items.map((item) => ( + +
+ handleChange(item.id, e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ))} +
+ )} + +
+ ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx new file mode 100644 index 00000000..cfec2d78 --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/capabilities-section.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Lightbulb } from 'lucide-react'; +import { ArrayFieldEditor } from './array-field-editor'; + +interface CapabilitiesSectionProps { + capabilities: string[]; + onChange: (capabilities: string[]) => void; +} + +export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) { + return ( + + + + + Core Capabilities + + + + + + + ); +} diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx new file mode 100644 index 00000000..1cdbac2f --- /dev/null +++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx @@ -0,0 +1,261 @@ +import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react'; +import { useState, useRef, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { ListChecks } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import type { SpecOutput } from '@automaker/spec-parser'; + +type Feature = SpecOutput['implemented_features'][number]; + +interface FeaturesSectionProps { + features: Feature[]; + onChange: (features: Feature[]) => void; +} + +interface FeatureWithId extends Feature { + _id: string; + _locationIds?: string[]; +} + +function generateId(): string { + return crypto.randomUUID(); +} + +function featureToInternal(feature: Feature): FeatureWithId { + return { + ...feature, + _id: generateId(), + _locationIds: feature.file_locations?.map(() => generateId()), + }; +} + +function internalToFeature(internal: FeatureWithId): Feature { + const { _id, _locationIds, ...feature } = internal; + return feature; +} + +interface FeatureCardProps { + feature: FeatureWithId; + index: number; + onChange: (feature: FeatureWithId) => void; + onRemove: () => void; +} + +function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) { + const [isOpen, setIsOpen] = useState(false); + + const handleNameChange = (name: string) => { + onChange({ ...feature, name }); + }; + + const handleDescriptionChange = (description: string) => { + onChange({ ...feature, description }); + }; + + const handleAddLocation = () => { + const locations = feature.file_locations || []; + const locationIds = feature._locationIds || []; + onChange({ + ...feature, + file_locations: [...locations, ''], + _locationIds: [...locationIds, generateId()], + }); + }; + + const handleRemoveLocation = (locId: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const newLocations = feature.file_locations?.filter((_, i) => i !== idx); + const newLocationIds = locationIds.filter((id) => id !== locId); + onChange({ + ...feature, + file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined, + _locationIds: newLocationIds.length > 0 ? newLocationIds : undefined, + }); + }; + + const handleLocationChange = (locId: string, value: string) => { + const locationIds = feature._locationIds || []; + const idx = locationIds.indexOf(locId); + if (idx === -1) return; + + const locations = [...(feature.file_locations || [])]; + locations[idx] = value; + onChange({ ...feature, file_locations: locations }); + }; + + return ( + + +
+ + + +
+ handleNameChange(e.target.value)} + placeholder="Feature name..." + className="font-medium" + /> +
+ + #{index + 1} + + +
+ +
+
+ +