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 && (
)}
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 (
{hasCustomIcon ? (
p.path === path);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- upsertAndSetCurrentProject(path, name, effectiveTheme);
+ // Theme handling (trashed project recovery or undefined for global) is done by the store
+ upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -198,7 +186,7 @@ export function ProjectSwitcher() {
});
}
}
- }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
+ }, [upsertAndSetCurrentProject, navigate]);
// Handler for creating initial spec from the setup dialog
const handleCreateInitialSpec = useCallback(async () => {
diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx
index 0baa81cf..05ff1328 100644
--- a/apps/ui/src/components/layout/sidebar.tsx
+++ b/apps/ui/src/components/layout/sidebar.tsx
@@ -4,7 +4,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
-import { useAppStore, type ThemeMode } from '@/store/app-store';
+import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
@@ -34,7 +34,6 @@ import {
useProjectCreation,
useSetupDialog,
useTrashOperations,
- useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
@@ -79,9 +78,6 @@ export function Sidebar() {
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
- // Project theme management (must come before useProjectCreation which uses globalTheme)
- const { globalTheme } = useProjectTheme();
-
// Project creation state and handlers
const {
showNewProjectModal,
@@ -97,9 +93,6 @@ export function Sidebar() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
- trashedProjects,
- currentProject,
- globalTheme,
upsertAndSetCurrentProject,
});
@@ -198,13 +191,8 @@ export function Sidebar() {
}
// Upsert project and set as current (handles both create and update cases)
- // Theme preservation is handled by the store action
- const trashedProject = trashedProjects.find((p) => p.path === path);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- upsertAndSetCurrentProject(path, name, effectiveTheme);
+ // Theme handling (trashed project recovery or undefined for global) is done by the store
+ upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -232,7 +220,7 @@ export function Sidebar() {
});
}
}
- }, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
+ }, [upsertAndSetCurrentProject]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx
index 3cda8229..c4956159 100644
--- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx
+++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx
@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
-import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
+import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -93,9 +93,10 @@ export function SidebarNavigation({
>
{item.isLoading ? (
-
diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts
index 2720bb98..45cd816a 100644
--- a/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts
+++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-creation.ts
@@ -6,20 +6,13 @@ const logger = createLogger('ProjectCreation');
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
-import type { ThemeMode } from '@/store/app-store';
-import type { TrashedProject, Project } from '@/lib/electron';
+import type { Project } from '@/lib/electron';
interface UseProjectCreationProps {
- trashedProjects: TrashedProject[];
- currentProject: Project | null;
- globalTheme: ThemeMode;
- upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
+ upsertAndSetCurrentProject: (path: string, name: string) => Project;
}
export function useProjectCreation({
- trashedProjects,
- currentProject,
- globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
// Modal state
@@ -67,14 +60,8 @@ export function useProjectCreation({
`
);
- // Determine theme: try trashed project theme, then current project theme, then global
- const trashedProject = trashedProjects.find((p) => p.path === projectPath);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
-
- upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
+ // Let the store handle theme (trashed project recovery or undefined for global)
+ upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
@@ -92,7 +79,7 @@ export function useProjectCreation({
throw error;
}
},
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
+ [upsertAndSetCurrentProject]
);
/**
@@ -169,14 +156,8 @@ export function useProjectCreation({
`
);
- // Determine theme
- const trashedProject = trashedProjects.find((p) => p.path === projectPath);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
-
- upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
+ // Let the store handle theme (trashed project recovery or undefined for global)
+ upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -194,7 +175,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
+ [upsertAndSetCurrentProject]
);
/**
@@ -244,14 +225,8 @@ export function useProjectCreation({
`
);
- // Determine theme
- const trashedProject = trashedProjects.find((p) => p.path === projectPath);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
-
- upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
+ // Let the store handle theme (trashed project recovery or undefined for global)
+ upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -269,7 +244,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
+ [upsertAndSetCurrentProject]
);
return {
diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx
index 88c31acc..f0fa9a45 100644
--- a/apps/ui/src/components/session-manager.tsx
+++ b/apps/ui/src/components/session-manager.tsx
@@ -16,8 +16,8 @@ import {
Check,
X,
ArchiveRestore,
- Loader2,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
@@ -466,7 +466,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/ui/button.tsx b/apps/ui/src/components/ui/button.tsx
index fa970a52..a7163ed3 100644
--- a/apps/ui/src/components/ui/button.tsx
+++ b/apps/ui/src/components/ui/button.tsx
@@ -1,9 +1,9 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
-import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
+import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -39,7 +39,7 @@ const buttonVariants = cva(
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
- return
;
+ return
;
}
function Button({
diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx
index 42b2d588..7b67fd9b 100644
--- a/apps/ui/src/components/ui/description-image-dropzone.tsx
+++ b/apps/ui/src/components/ui/description-image-dropzone.tsx
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
-import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
+import { ImageIcon, X, FileText } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
@@ -431,7 +432,7 @@ export function DescriptionImageDropZone({
{/* Processing indicator */}
{isProcessing && (
-
+
Processing files...
)}
diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx
index ec4ef205..23837cc1 100644
--- a/apps/ui/src/components/ui/feature-image-upload.tsx
+++ b/apps/ui/src/components/ui/feature-image-upload.tsx
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
-import { ImageIcon, X, Upload } from 'lucide-react';
+import { ImageIcon, X } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import {
fileToBase64,
generateImageId,
@@ -196,7 +197,7 @@ export function FeatureImageUpload({
)}
>
{isProcessing ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx
index 803ff46c..e0e51ea0 100644
--- a/apps/ui/src/components/ui/git-diff-panel.tsx
+++ b/apps/ui/src/components/ui/git-diff-panel.tsx
@@ -9,11 +9,11 @@ import {
FilePen,
ChevronDown,
ChevronRight,
- Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import type { FileStatus } from '@/types/electron';
@@ -484,7 +484,7 @@ export function GitDiffPanel({
{isLoading ? (
-
+
Loading changes...
) : error ? (
diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx
index cdd7b396..dcaf892d 100644
--- a/apps/ui/src/components/ui/image-drop-zone.tsx
+++ b/apps/ui/src/components/ui/image-drop-zone.tsx
@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
-import { ImageIcon, X, Upload } from 'lucide-react';
+import { ImageIcon, X } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
@@ -204,7 +205,7 @@ export function ImageDropZone({
)}
>
{isProcessing ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/ui/loading-state.tsx b/apps/ui/src/components/ui/loading-state.tsx
index 9ae6ff3b..60695e4c 100644
--- a/apps/ui/src/components/ui/loading-state.tsx
+++ b/apps/ui/src/components/ui/loading-state.tsx
@@ -1,17 +1,15 @@
-import { Loader2 } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
- /** Optional custom size class for the spinner (default: h-8 w-8) */
- size?: string;
}
-export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
+export function LoadingState({ message }: LoadingStateProps) {
return (
-
- {message &&
{message}
}
+
+ {message &&
{message}
}
);
}
diff --git a/apps/ui/src/components/ui/log-viewer.tsx b/apps/ui/src/components/ui/log-viewer.tsx
index 1d14a14e..65426f8b 100644
--- a/apps/ui/src/components/ui/log-viewer.tsx
+++ b/apps/ui/src/components/ui/log-viewer.tsx
@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
- Loader2,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'completed':
return
;
case 'in_progress':
- return
;
+ return
;
case 'pending':
return
;
default:
diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx
index a62254c7..5f0b6633 100644
--- a/apps/ui/src/components/ui/provider-icon.tsx
+++ b/apps/ui/src/components/ui/provider-icon.tsx
@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (modelStr.includes('grok')) {
return 'grok';
}
- if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
+ // Cursor models - canonical format includes 'cursor-' prefix
+ // Also support legacy IDs for backward compatibility
+ if (
+ modelStr.includes('cursor') ||
+ modelStr === 'auto' ||
+ modelStr === 'composer-1' ||
+ modelStr === 'cursor-auto' ||
+ modelStr === 'cursor-composer-1'
+ ) {
return 'cursor';
}
diff --git a/apps/ui/src/components/ui/spinner.tsx b/apps/ui/src/components/ui/spinner.tsx
new file mode 100644
index 00000000..c66b7684
--- /dev/null
+++ b/apps/ui/src/components/ui/spinner.tsx
@@ -0,0 +1,32 @@
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
+const sizeClasses: Record
= {
+ xs: 'h-3 w-3',
+ sm: 'h-4 w-4',
+ md: 'h-5 w-5',
+ lg: 'h-6 w-6',
+ xl: 'h-8 w-8',
+};
+
+interface SpinnerProps {
+ /** Size of the spinner */
+ size?: SpinnerSize;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * Themed spinner component using the primary brand color.
+ * Use this for all loading indicators throughout the app for consistency.
+ */
+export function Spinner({ size = 'md', className }: SpinnerProps) {
+ return (
+
+ );
+}
diff --git a/apps/ui/src/components/ui/task-progress-panel.tsx b/apps/ui/src/components/ui/task-progress-panel.tsx
index 414be1e7..4fecefbc 100644
--- a/apps/ui/src/components/ui/task-progress-panel.tsx
+++ b/apps/ui/src/components/ui/task-progress-panel.tsx
@@ -5,7 +5,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
-import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
+import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
@@ -260,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && }
- {isActive && }
+ {isActive && }
{isPending && }
diff --git a/apps/ui/src/components/ui/xml-syntax-editor.tsx b/apps/ui/src/components/ui/xml-syntax-editor.tsx
index 8929d4a8..6f9aac33 100644
--- a/apps/ui/src/components/ui/xml-syntax-editor.tsx
+++ b/apps/ui/src/components/ui/xml-syntax-editor.tsx
@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
-import { Extension } from '@codemirror/state';
-import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
-import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}
-// Syntax highlighting that uses CSS variables from the app's theme system
-// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
-const syntaxColors = HighlightStyle.define([
- // XML tags - use primary color
- { tag: t.tagName, color: 'var(--primary)' },
- { tag: t.angleBracket, color: 'var(--muted-foreground)' },
-
- // Attributes
- { tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
- { tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
-
- // Strings and content
- { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
- { tag: t.content, color: 'var(--foreground)' },
-
- // Comments
- { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
-
- // Special
- { tag: t.processingInstruction, color: 'var(--muted-foreground)' },
- { tag: t.documentMeta, color: 'var(--muted-foreground)' },
-]);
-
-// Editor theme using CSS variables
+// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
- fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
- color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
- fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
- caretColor: 'var(--primary)',
- },
- '.cm-cursor, .cm-dropCursor': {
- borderLeftColor: 'var(--primary)',
- },
- '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
- backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
- '.cm-placeholder': {
- color: 'var(--muted-foreground)',
- fontStyle: 'italic',
- },
});
-// Combine all extensions
-const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
-
export function XmlSyntaxEditor({
value,
onChange,
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
;
@@ -20,7 +20,7 @@ export interface XtermLogViewerRef {
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
- /** Font size in pixels (default: 13) */
+ /** Font size in pixels (uses terminal settings if not provided) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
@@ -42,7 +42,7 @@ export const XtermLogViewer = forwardRef
(
{
initialContent,
- fontSize = 13,
+ fontSize,
autoScroll = true,
className,
minHeight = 300,
@@ -58,9 +58,14 @@ export const XtermLogViewer = forwardRef
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef([]);
- // Get theme from store
+ // Get theme and font settings from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
+ const terminalFontFamily = useAppStore((state) => state.terminalState.fontFamily);
+ const terminalFontSize = useAppStore((state) => state.terminalState.defaultFontSize);
+
+ // Use prop if provided, otherwise use store value, fallback to 13
+ const effectiveFontSize = fontSize ?? terminalFontSize ?? 13;
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
@@ -102,12 +107,17 @@ export const XtermLogViewer = forwardRef
const terminalTheme = getTerminalTheme(resolvedTheme);
+ // Get font settings from store at initialization time
+ const terminalState = useAppStore.getState().terminalState;
+ const fontFamily = getTerminalFontFamily(terminalState.fontFamily);
+ const initFontSize = fontSize ?? terminalState.defaultFontSize ?? 13;
+
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
- fontSize,
- fontFamily: DEFAULT_TERMINAL_FONT,
+ fontSize: initFontSize,
+ fontFamily,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
@@ -181,10 +191,18 @@ export const XtermLogViewer = forwardRef
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
- xtermRef.current.options.fontSize = fontSize;
+ xtermRef.current.options.fontSize = effectiveFontSize;
fitAddonRef.current?.fit();
}
- }, [fontSize, isReady]);
+ }, [effectiveFontSize, isReady]);
+
+ // Update font family when it changes
+ useEffect(() => {
+ if (xtermRef.current && isReady) {
+ xtermRef.current.options.fontFamily = getTerminalFontFamily(terminalFontFamily);
+ fitAddonRef.current?.fit();
+ }
+ }, [terminalFontFamily, isReady]);
// Handle resize
useEffect(() => {
diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx
index ac15a519..26c100ce 100644
--- a/apps/ui/src/components/usage-popover.tsx
+++ b/apps/ui/src/components/usage-popover.tsx
@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -449,7 +450,7 @@ export function UsagePopover() {
) : !claudeUsage ? (
-
+
Loading usage data...
) : (
@@ -568,7 +569,7 @@ export function UsagePopover() {
) : !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() {
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)
{isAnalyzing ? (
<>
-
+
Analyzing...
>
) : (
@@ -771,7 +771,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
) : isAnalyzing ? (
-
+
Scanning project files...
) : projectAnalysis ? (
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
-
+
Generating...
>
) : (
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
-
+
Generating...
>
) : (
diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx
index 7928c21c..0aa80462 100644
--- a/apps/ui/src/components/views/board-view.tsx
+++ b/apps/ui/src/components/views/board-view.tsx
@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
-import { RefreshCw } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -856,68 +856,9 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
- // Client-side auto mode: periodically check for backlog items and move them to in-progress
- // Use a ref to track the latest auto mode state so async operations always check the current value
- const autoModeRunningRef = useRef(autoMode.isRunning);
- useEffect(() => {
- autoModeRunningRef.current = autoMode.isRunning;
- }, [autoMode.isRunning]);
-
- // Use a ref to track the latest features to avoid effect re-runs when features change
- const hookFeaturesRef = useRef(hookFeatures);
- useEffect(() => {
- hookFeaturesRef.current = hookFeatures;
- }, [hookFeatures]);
-
- // Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
- const runningAutoTasksRef = useRef(runningAutoTasks);
- useEffect(() => {
- runningAutoTasksRef.current = runningAutoTasks;
- }, [runningAutoTasks]);
-
- // Keep latest start handler without retriggering the auto mode effect
- const handleStartImplementationRef = useRef(handleStartImplementation);
- useEffect(() => {
- handleStartImplementationRef.current = handleStartImplementation;
- }, [handleStartImplementation]);
-
- // Track features that are pending (started but not yet confirmed running)
- const pendingFeaturesRef = useRef>(new Set());
-
- // Listen to auto mode events to remove features from pending when they start running
- useEffect(() => {
- const api = getElectronAPI();
- if (!api?.autoMode) return;
-
- const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
- if (!currentProject) return;
-
- // Only process events for the current project
- const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
- if (eventProjectPath && eventProjectPath !== currentProject.path) {
- return;
- }
-
- switch (event.type) {
- case 'auto_mode_feature_start':
- // Feature is now confirmed running - remove from pending
- if (event.featureId) {
- pendingFeaturesRef.current.delete(event.featureId);
- }
- break;
-
- case 'auto_mode_feature_complete':
- case 'auto_mode_error':
- // Feature completed or errored - remove from pending if still there
- if (event.featureId) {
- pendingFeaturesRef.current.delete(event.featureId);
- }
- break;
- }
- });
-
- return unsubscribe;
- }, [currentProject]);
+ // NOTE: Auto mode polling loop has been moved to the backend.
+ // The frontend now just toggles the backend's auto loop via API calls.
+ // See use-auto-mode.ts for the start/stop logic that calls the backend.
// Listen for backlog plan events (for background generation)
useEffect(() => {
@@ -976,219 +917,6 @@ export function BoardView() {
};
}, [currentProject, pendingBacklogPlan]);
- useEffect(() => {
- logger.info(
- '[AutoMode] Effect triggered - isRunning:',
- autoMode.isRunning,
- 'hasProject:',
- !!currentProject
- );
- if (!autoMode.isRunning || !currentProject) {
- return;
- }
-
- logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
- let isChecking = false;
- let isActive = true; // Track if this effect is still active
-
- const checkAndStartFeatures = async () => {
- // Check if auto mode is still running and effect is still active
- // Use ref to get the latest value, not the closure value
- if (!isActive || !autoModeRunningRef.current || !currentProject) {
- return;
- }
-
- // Prevent concurrent executions
- if (isChecking) {
- return;
- }
-
- isChecking = true;
- try {
- // Double-check auto mode is still running before proceeding
- if (!isActive || !autoModeRunningRef.current || !currentProject) {
- logger.debug(
- '[AutoMode] Skipping check - isActive:',
- isActive,
- 'autoModeRunning:',
- autoModeRunningRef.current,
- 'hasProject:',
- !!currentProject
- );
- return;
- }
-
- // Count currently running tasks + pending features
- // Use ref to get the latest running tasks without causing effect re-runs
- const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
- const availableSlots = maxConcurrency - currentRunning;
- logger.debug(
- '[AutoMode] Checking features - running:',
- currentRunning,
- 'available slots:',
- availableSlots
- );
-
- // No available slots, skip check
- if (availableSlots <= 0) {
- return;
- }
-
- // Filter backlog features by the currently selected worktree branch
- // This logic mirrors use-board-column-features.ts for consistency.
- // HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
- // so we fall back to "all backlog features" when none are visible in the current view.
- // Use ref to get the latest features without causing effect re-runs
- const currentFeatures = hookFeaturesRef.current;
- const backlogFeaturesInView = currentFeatures.filter((f) => {
- if (f.status !== 'backlog') return false;
-
- const featureBranch = f.branchName;
-
- // Features without branchName are considered unassigned (show only on primary worktree)
- if (!featureBranch) {
- // No branch assigned - show only when viewing primary worktree
- const isViewingPrimary = currentWorktreePath === null;
- return isViewingPrimary;
- }
-
- if (currentWorktreeBranch === null) {
- // We're viewing main but branch hasn't been initialized yet
- // Show features assigned to primary worktree's branch
- return currentProject.path
- ? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
- : false;
- }
-
- // Match by branch name
- return featureBranch === currentWorktreeBranch;
- });
-
- const backlogFeatures =
- backlogFeaturesInView.length > 0
- ? backlogFeaturesInView
- : currentFeatures.filter((f) => f.status === 'backlog');
-
- logger.debug(
- '[AutoMode] Features - total:',
- currentFeatures.length,
- 'backlog in view:',
- backlogFeaturesInView.length,
- 'backlog total:',
- backlogFeatures.length
- );
-
- if (backlogFeatures.length === 0) {
- logger.debug(
- '[AutoMode] No backlog features found, statuses:',
- currentFeatures.map((f) => f.status).join(', ')
- );
- return;
- }
-
- // Sort by priority (lower number = higher priority, priority 1 is highest)
- const sortedBacklog = [...backlogFeatures].sort(
- (a, b) => (a.priority || 999) - (b.priority || 999)
- );
-
- // Filter out features with blocking dependencies if dependency blocking is enabled
- // NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
- // should NOT exclude blocked features in that mode.
- const eligibleFeatures =
- enableDependencyBlocking && !skipVerificationInAutoMode
- ? sortedBacklog.filter((f) => {
- const blockingDeps = getBlockingDependencies(f, currentFeatures);
- if (blockingDeps.length > 0) {
- logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
- }
- return blockingDeps.length === 0;
- })
- : sortedBacklog;
-
- logger.debug(
- '[AutoMode] Eligible features after dep check:',
- eligibleFeatures.length,
- 'dependency blocking enabled:',
- enableDependencyBlocking
- );
-
- // Start features up to available slots
- const featuresToStart = eligibleFeatures.slice(0, availableSlots);
- const startImplementation = handleStartImplementationRef.current;
- if (!startImplementation) {
- return;
- }
-
- logger.info(
- '[AutoMode] Starting',
- featuresToStart.length,
- 'features:',
- featuresToStart.map((f) => f.id).join(', ')
- );
-
- for (const feature of featuresToStart) {
- // Check again before starting each feature
- if (!isActive || !autoModeRunningRef.current || !currentProject) {
- return;
- }
-
- // Simplified: No worktree creation on client - server derives workDir from feature.branchName
- // If feature has no branchName, assign it to the primary branch so it can run consistently
- // even when the user is viewing a non-primary worktree.
- if (!feature.branchName) {
- const primaryBranch =
- (currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
- 'main';
- await persistFeatureUpdate(feature.id, {
- branchName: primaryBranch,
- });
- }
-
- // Final check before starting implementation
- if (!isActive || !autoModeRunningRef.current || !currentProject) {
- return;
- }
-
- // Start the implementation - server will derive workDir from feature.branchName
- const started = await startImplementation(feature);
-
- // If successfully started, track it as pending until we receive the start event
- if (started) {
- pendingFeaturesRef.current.add(feature.id);
- }
- }
- } finally {
- isChecking = false;
- }
- };
-
- // Check immediately, then every 3 seconds
- checkAndStartFeatures();
- const interval = setInterval(checkAndStartFeatures, 3000);
-
- return () => {
- // Mark as inactive to prevent any pending async operations from continuing
- isActive = false;
- clearInterval(interval);
- // Clear pending features when effect unmounts or dependencies change
- pendingFeaturesRef.current.clear();
- };
- }, [
- autoMode.isRunning,
- currentProject,
- // runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
- // that would clear pendingFeaturesRef and cause concurrency issues
- maxConcurrency,
- // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
- currentWorktreeBranch,
- currentWorktreePath,
- getPrimaryWorktreeBranch,
- isPrimaryWorktreeBranch,
- enableDependencyBlocking,
- skipVerificationInAutoMode,
- persistFeatureUpdate,
- ]);
-
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -1384,7 +1112,7 @@ export function BoardView() {
if (isLoading) {
return (
-
+
);
}
@@ -1403,9 +1131,13 @@ export function BoardView() {
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
- autoMode.start();
+ autoMode.start().catch((error) => {
+ logger.error('[AutoMode] Failed to start:', error);
+ });
} else {
- autoMode.stop();
+ autoMode.stop().catch((error) => {
+ logger.error('[AutoMode] Failed to stop:', error);
+ });
}
}}
onOpenPlanDialog={() => setShowPlanDialog(true)}
diff --git a/apps/ui/src/components/views/board-view/board-search-bar.tsx b/apps/ui/src/components/views/board-view/board-search-bar.tsx
index f200ace5..ed4be402 100644
--- a/apps/ui/src/components/views/board-view/board-search-bar.tsx
+++ b/apps/ui/src/components/views/board-view/board-search-bar.tsx
@@ -1,6 +1,7 @@
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
-import { Search, X, Loader2 } from 'lucide-react';
+import { Search, X } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
interface BoardSearchBarProps {
searchQuery: string;
@@ -75,7 +76,7 @@ export function BoardSearchBar({
title="Creating App Specification"
data-testid="spec-creation-badge"
>
-
+
Creating spec
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
index 6916222e..453c94e3 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx
@@ -11,16 +11,8 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
-import {
- Brain,
- ListTodo,
- Sparkles,
- Expand,
- CheckCircle2,
- Circle,
- Loader2,
- Wrench,
-} from 'lucide-react';
+import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
@@ -338,7 +330,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
) : todo.status === 'in_progress' ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
index 6a2dfdcb..73d1dc3a 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
@@ -13,7 +13,6 @@ import {
import {
GripVertical,
Edit,
- Loader2,
Trash2,
FileText,
MoreVertical,
@@ -21,6 +20,7 @@ import {
ChevronUp,
GitFork,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
@@ -65,7 +65,7 @@ export function CardHeaderSection({
{isCurrentAutoTask && !isSelectionMode && (
-
+
{feature.startedAt && (
{feature.titleGenerating ? (
-
+
Generating title...
) : feature.title ? (
diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
index 590d7789..e4ba03d4 100644
--- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
@@ -170,7 +170,7 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
- const [modelEntry, setModelEntry] = useState({ model: 'opus' });
+ const [modelEntry, setModelEntry] = useState({ model: 'claude-opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
index 68e60194..ba78f1c8 100644
--- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
@@ -6,7 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
+import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
@@ -353,7 +354,7 @@ export function AgentOutputModal({
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
-
+
)}
Agent Output
@@ -439,7 +440,7 @@ export function AgentOutputModal({
/>
) : (
-
+
Loading...
)}
@@ -457,7 +458,7 @@ export function AgentOutputModal({
>
{isLoading && !output ? (
-
+
Loading output...
) : !output ? (
diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
index c82b7157..afc770e7 100644
--- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
@@ -11,16 +11,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
-import {
- Loader2,
- Wand2,
- Check,
- Plus,
- Pencil,
- Trash2,
- ChevronDown,
- ChevronRight,
-} from 'lucide-react';
+import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -287,8 +279,7 @@ export function BacklogPlanDialog({
{isGeneratingPlan && (
- A plan is currently being generated in
- the background...
+ A plan is currently being generated in the background...
)}
@@ -405,7 +396,7 @@ export function BacklogPlanDialog({
case 'applying':
return (
);
@@ -452,7 +443,7 @@ export function BacklogPlanDialog({
{isGeneratingPlan ? (
<>
-
+
Generating...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx
index 84b1a8fc..2b325fee 100644
--- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx
@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
-import { GitCommit, Loader2, Sparkles } from 'lucide-react';
+import { GitCommit, Sparkles } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
{isLoading ? (
<>
-
+
Committing...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx
index 886cf2f4..47153f2e 100644
--- a/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx
@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
-import { GitBranchPlus, Loader2 } from 'lucide-react';
+import { GitBranchPlus } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
path: string;
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
{isCreating ? (
<>
-
+
Creating...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx
index 125e8416..1d3677d6 100644
--- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx
@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
-import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
+import { GitPullRequest, ExternalLink } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -405,7 +406,7 @@ export function CreatePRDialog({
{isLoading ? (
<>
-
+
Creating...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
index 8a675069..1912e946 100644
--- a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
+import { GitBranch, AlertCircle } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
{isLoading ? (
<>
-
+
Creating...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
index 718bef0c..e366b03e 100644
--- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
-import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
+import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
{isLoading ? (
<>
-
+
Deleting...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
index c04d4b34..1a5c187d 100644
--- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx
@@ -28,6 +28,7 @@ import { toast } from 'sonner';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
+import { migrateModelId } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
@@ -107,9 +108,9 @@ export function EditFeatureDialog({
feature?.requirePlanApproval ?? false
);
- // Model selection state
+ // Model selection state - migrate legacy model IDs to canonical format
const [modelEntry, setModelEntry] = useState(() => ({
- model: (feature?.model as ModelAlias) || 'opus',
+ model: migrateModelId(feature?.model) || 'claude-opus',
thinkingLevel: feature?.thinkingLevel || 'none',
reasoningEffort: feature?.reasoningEffort || 'none',
}));
@@ -157,9 +158,9 @@ export function EditFeatureDialog({
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
- // Reset model entry
+ // Reset model entry - migrate legacy model IDs
setModelEntry({
- model: (feature.model as ModelAlias) || 'opus',
+ model: migrateModelId(feature.model) || 'claude-opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx
index 2be7d32f..f98908f9 100644
--- a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx
@@ -126,7 +126,7 @@ export function MassEditDialog({
});
// Field values
- const [model, setModel] = useState('sonnet');
+ const [model, setModel] = useState('claude-sonnet');
const [thinkingLevel, setThinkingLevel] = useState('none');
const [planningMode, setPlanningMode] = useState('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
@@ -160,7 +160,7 @@ export function MassEditDialog({
skipTests: false,
branchName: false,
});
- setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
+ setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
diff --git a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx
index 1813d43f..e5a255f3 100644
--- a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx
@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
-
+
Merging...
>
) : (
diff --git a/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx
index 72c80d2f..d49d408e 100644
--- a/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx
@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
-import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
+import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
interface PlanApprovalDialogProps {
open: boolean;
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
{isLoading ? (
-
+
) : (
)}
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/views/board-view/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx
index 33298394..2f75cff2 100644
--- a/apps/ui/src/components/views/board-view/init-script-indicator.tsx
+++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
-import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
+import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
@@ -65,7 +66,7 @@ function SingleIndicator({
{/* Header */}
- {status === 'running' && }
+ {status === 'running' && }
{status === 'success' && }
{status === 'failed' && }
diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
index ddd05ff9..918988e9 100644
--- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
+++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
@@ -1,6 +1,7 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -90,9 +91,11 @@ function UsageItem({
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
-
+ {isLoading ? (
+
+ ) : (
+
+ )}
{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"
>
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 (
-
+
Validating...
);
@@ -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({
-
+ {refreshing ? : }
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() {
-
+ {refreshing ? : }
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
- {isSaving ? (
-
- ) : (
-
- )}
+ {isSaving ? : }
Save
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() {
-
+ {refreshing ? (
+
+ ) : (
+
+ )}
Refresh
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() {
-
+ {loading ? (
+
+ ) : (
+
+ )}
Refresh
{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 */}
+
+
+ Default External Terminal
+
+
+
+
+
+ Terminal to use when selecting "Open in Terminal" from the worktree menu
+
+
{
+ setDefaultTerminalId(value === 'integrated' ? null : value);
+ toast.success(
+ value === 'integrated'
+ ? 'Integrated terminal set as default'
+ : 'Default terminal changed'
+ );
+ }}
+ >
+
+
+
+
+
+
+
+ Integrated Terminal
+
+
+ {terminals.map((terminal) => {
+ const TerminalIcon = getTerminalIcon(terminal.id);
+ return (
+
+
+
+ {terminal.name}
+
+
+ );
+ })}
+
+
+ {terminals.length === 0 && !isRefreshing && (
+
+ No external terminals detected. Click refresh to re-scan.
+
+ )}
+
+
+ {/* Default Open Mode */}
+
+
Default Open Mode
+
+ How to open the integrated terminal when using "Open in Terminal" from the worktree menu
+
+
{
+ setOpenTerminalMode(value);
+ toast.success(
+ value === 'newTab'
+ ? 'New terminals will open in new tabs'
+ : 'New terminals will split the current tab'
+ );
+ }}
+ >
+
+
+
+
+
+
+
+ New Tab
+
+
+
+
+
+ Split Current Tab
+
+
+
+
+
+
{/* Font Family */}
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
-
+ {isChecking ? : }
@@ -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
-
+ {isChecking ? : }
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()}
-
+ {isChecking ? : }
@@ -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()}
-
+ {isChecking ? : }
@@ -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()}
-
+ {isChecking ? : }
@@ -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 && (
{isDeletingApiKey ? (
-
+
) : (
)}
@@ -553,7 +549,7 @@ function CursorContent() {
Cursor CLI Status
-
+ {isChecking ? : }
@@ -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
-
+ {isChecking ? : }
@@ -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
-
+ {isChecking ? : }
@@ -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"
+ />
+ handleRemove(item.id)}
+ className="shrink-0 text-muted-foreground hover:text-destructive"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+ {addLabel}
+
+
+ );
+}
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 (
+
+
+
+
+
+ {isOpen ? : }
+
+
+
+ handleNameChange(e.target.value)}
+ placeholder="Feature name..."
+ className="font-medium"
+ />
+
+
+ #{index + 1}
+
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+ File Locations
+
+
+
+ Add
+
+
+ {(feature.file_locations || []).length === 0 ? (
+
No file locations specified.
+ ) : (
+
+ {(feature.file_locations || []).map((location, idx) => {
+ const locId = feature._locationIds?.[idx] || `fallback-${idx}`;
+ return (
+
+ handleLocationChange(locId, e.target.value)}
+ placeholder="e.g., src/components/feature.tsx"
+ className="flex-1 font-mono text-sm"
+ />
+ handleRemoveLocation(locId)}
+ className="shrink-0 text-muted-foreground hover:text-destructive h-8 w-8"
+ >
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function FeaturesSection({ features, onChange }: FeaturesSectionProps) {
+ // Track features with stable IDs
+ const [items, setItems] = useState(() => features.map(featureToInternal));
+
+ // Track if we're making an internal change to avoid sync loops
+ const isInternalChange = useRef(false);
+
+ // Sync external features to internal items when features change externally
+ useEffect(() => {
+ if (isInternalChange.current) {
+ isInternalChange.current = false;
+ return;
+ }
+ setItems(features.map(featureToInternal));
+ }, [features]);
+
+ const handleAdd = () => {
+ const newItems = [...items, featureToInternal({ name: '', description: '' })];
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToFeature));
+ };
+
+ const handleRemove = (id: string) => {
+ const newItems = items.filter((item) => item._id !== id);
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToFeature));
+ };
+
+ const handleFeatureChange = (id: string, feature: FeatureWithId) => {
+ const newItems = items.map((item) => (item._id === id ? feature : item));
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToFeature));
+ };
+
+ return (
+
+
+
+
+ Implemented Features
+
+ {items.length}
+
+
+
+
+ {items.length === 0 ? (
+
+ No features added yet. Click below to add implemented features.
+
+ ) : (
+
+ {items.map((feature, index) => (
+ handleFeatureChange(feature._id, f)}
+ onRemove={() => handleRemove(feature._id)}
+ />
+ ))}
+
+ )}
+
+
+ Add Feature
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/index.ts b/apps/ui/src/components/views/spec-view/components/edit-mode/index.ts
new file mode 100644
index 00000000..aa9b1ebf
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/index.ts
@@ -0,0 +1,7 @@
+export { ArrayFieldEditor } from './array-field-editor';
+export { ProjectInfoSection } from './project-info-section';
+export { TechStackSection } from './tech-stack-section';
+export { CapabilitiesSection } from './capabilities-section';
+export { FeaturesSection } from './features-section';
+export { RoadmapSection } from './roadmap-section';
+export { RequirementsSection, GuidelinesSection } from './optional-sections';
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/optional-sections.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/optional-sections.tsx
new file mode 100644
index 00000000..a71b2170
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/optional-sections.tsx
@@ -0,0 +1,59 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { ScrollText, Wrench } from 'lucide-react';
+import { ArrayFieldEditor } from './array-field-editor';
+
+interface RequirementsSectionProps {
+ requirements: string[];
+ onChange: (requirements: string[]) => void;
+}
+
+export function RequirementsSection({ requirements, onChange }: RequirementsSectionProps) {
+ return (
+
+
+
+
+ Additional Requirements
+ (Optional)
+
+
+
+
+
+
+ );
+}
+
+interface GuidelinesSectionProps {
+ guidelines: string[];
+ onChange: (guidelines: string[]) => void;
+}
+
+export function GuidelinesSection({ guidelines, onChange }: GuidelinesSectionProps) {
+ return (
+
+
+
+
+ Development Guidelines
+ (Optional)
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/project-info-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/project-info-section.tsx
new file mode 100644
index 00000000..74a25836
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/project-info-section.tsx
@@ -0,0 +1,51 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { FileText } from 'lucide-react';
+
+interface ProjectInfoSectionProps {
+ projectName: string;
+ overview: string;
+ onProjectNameChange: (value: string) => void;
+ onOverviewChange: (value: string) => void;
+}
+
+export function ProjectInfoSection({
+ projectName,
+ overview,
+ onProjectNameChange,
+ onOverviewChange,
+}: ProjectInfoSectionProps) {
+ return (
+
+
+
+
+ Project Information
+
+
+
+
+ Project Name
+ onProjectNameChange(e.target.value)}
+ placeholder="Enter project name..."
+ />
+
+
+ Overview
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx
new file mode 100644
index 00000000..6275eebd
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx
@@ -0,0 +1,195 @@
+import { Plus, X, Map as MapIcon } 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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import type { SpecOutput } from '@automaker/spec-parser';
+
+type RoadmapPhase = NonNullable[number];
+type PhaseStatus = 'completed' | 'in_progress' | 'pending';
+
+interface PhaseWithId extends RoadmapPhase {
+ _id: string;
+}
+
+function generateId(): string {
+ return crypto.randomUUID();
+}
+
+function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
+ return { ...phase, _id: generateId() };
+}
+
+function internalToPhase(internal: PhaseWithId): RoadmapPhase {
+ const { _id, ...phase } = internal;
+ return phase;
+}
+
+interface RoadmapSectionProps {
+ phases: RoadmapPhase[];
+ onChange: (phases: RoadmapPhase[]) => void;
+}
+
+interface PhaseCardProps {
+ phase: PhaseWithId;
+ onChange: (phase: PhaseWithId) => void;
+ onRemove: () => void;
+}
+
+function PhaseCard({ phase, onChange, onRemove }: PhaseCardProps) {
+ const handlePhaseNameChange = (name: string) => {
+ onChange({ ...phase, phase: name });
+ };
+
+ const handleStatusChange = (status: PhaseStatus) => {
+ onChange({ ...phase, status });
+ };
+
+ const handleDescriptionChange = (description: string) => {
+ onChange({ ...phase, description });
+ };
+
+ return (
+
+
+
+
+
+
+ Phase Name
+ handlePhaseNameChange(e.target.value)}
+ placeholder="Phase name..."
+ />
+
+
+ Status
+
+
+
+
+
+ Pending
+ In Progress
+ Completed
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ );
+}
+
+export function RoadmapSection({ phases, onChange }: RoadmapSectionProps) {
+ // Track phases with stable IDs
+ const [items, setItems] = useState(() => phases.map(phaseToInternal));
+
+ // Track if we're making an internal change to avoid sync loops
+ const isInternalChange = useRef(false);
+
+ // Sync external phases to internal items when phases change externally
+ // Preserve existing IDs where possible to avoid unnecessary remounts
+ useEffect(() => {
+ if (isInternalChange.current) {
+ isInternalChange.current = false;
+ return;
+ }
+ setItems((currentItems) => {
+ return phases.map((phase, index) => {
+ // Try to find existing item by index (positional matching)
+ const existingItem = currentItems[index];
+ if (existingItem) {
+ // Reuse the existing ID, update the phase data
+ return { ...phase, _id: existingItem._id };
+ }
+ // New phase - generate new ID
+ return phaseToInternal(phase);
+ });
+ });
+ }, [phases]);
+
+ const handleAdd = () => {
+ const newItems = [...items, phaseToInternal({ phase: '', status: 'pending', description: '' })];
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToPhase));
+ };
+
+ const handleRemove = (id: string) => {
+ const newItems = items.filter((item) => item._id !== id);
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToPhase));
+ };
+
+ const handlePhaseChange = (id: string, phase: PhaseWithId) => {
+ const newItems = items.map((item) => (item._id === id ? phase : item));
+ setItems(newItems);
+ isInternalChange.current = true;
+ onChange(newItems.map(internalToPhase));
+ };
+
+ return (
+
+
+
+
+ Implementation Roadmap
+
+
+
+ {items.length === 0 ? (
+
+ No roadmap phases defined. Add phases to track implementation progress.
+
+ ) : (
+
+ {items.map((phase) => (
+
handlePhaseChange(phase._id, p)}
+ onRemove={() => handleRemove(phase._id)}
+ />
+ ))}
+
+ )}
+
+
+ Add Phase
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/tech-stack-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/tech-stack-section.tsx
new file mode 100644
index 00000000..4002049e
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/tech-stack-section.tsx
@@ -0,0 +1,30 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Cpu } from 'lucide-react';
+import { ArrayFieldEditor } from './array-field-editor';
+
+interface TechStackSectionProps {
+ technologies: string[];
+ onChange: (technologies: string[]) => void;
+}
+
+export function TechStackSection({ technologies, onChange }: TechStackSectionProps) {
+ return (
+
+
+
+
+ Technology Stack
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/index.ts b/apps/ui/src/components/views/spec-view/components/index.ts
index 07fbbdf3..9773b7ce 100644
--- a/apps/ui/src/components/views/spec-view/components/index.ts
+++ b/apps/ui/src/components/views/spec-view/components/index.ts
@@ -1,3 +1,6 @@
export { SpecHeader } from './spec-header';
export { SpecEditor } from './spec-editor';
export { SpecEmptyState } from './spec-empty-state';
+export { SpecModeTabs } from './spec-mode-tabs';
+export { SpecViewMode } from './spec-view-mode';
+export { SpecEditMode } from './spec-edit-mode';
diff --git a/apps/ui/src/components/views/spec-view/components/spec-edit-mode.tsx b/apps/ui/src/components/views/spec-view/components/spec-edit-mode.tsx
new file mode 100644
index 00000000..39519664
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/spec-edit-mode.tsx
@@ -0,0 +1,118 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+import type { SpecOutput } from '@automaker/spec-parser';
+import { specToXml } from '@automaker/spec-parser';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ ProjectInfoSection,
+ TechStackSection,
+ CapabilitiesSection,
+ FeaturesSection,
+ RoadmapSection,
+ RequirementsSection,
+ GuidelinesSection,
+} from './edit-mode';
+
+interface SpecEditModeProps {
+ spec: SpecOutput;
+ onChange: (xmlContent: string) => void;
+}
+
+export function SpecEditMode({ spec, onChange }: SpecEditModeProps) {
+ // Local state for form editing
+ const [formData, setFormData] = useState(spec);
+
+ // Track the last spec we synced FROM to detect external changes
+ const lastExternalSpecRef = useRef(JSON.stringify(spec));
+
+ // Flag to prevent re-syncing when we caused the change
+ const isInternalChangeRef = useRef(false);
+
+ // Reset form only when spec changes externally (e.g., after save, sync, or regenerate)
+ useEffect(() => {
+ const specJson = JSON.stringify(spec);
+
+ // If we caused this change (internal), just update the ref and skip reset
+ if (isInternalChangeRef.current) {
+ lastExternalSpecRef.current = specJson;
+ isInternalChangeRef.current = false;
+ return;
+ }
+
+ // External change - reset form data
+ if (specJson !== lastExternalSpecRef.current) {
+ lastExternalSpecRef.current = specJson;
+ setFormData(spec);
+ }
+ }, [spec]);
+
+ // Update a field and notify parent
+ const updateField = useCallback(
+ (field: K, value: SpecOutput[K]) => {
+ setFormData((prev) => {
+ const newData = { ...prev, [field]: value };
+ // Mark as internal change before notifying parent
+ isInternalChangeRef.current = true;
+ const xmlContent = specToXml(newData);
+ onChange(xmlContent);
+ return newData;
+ });
+ },
+ [onChange]
+ );
+
+ return (
+
+
+ {/* Project Information */}
+
updateField('project_name', value)}
+ onOverviewChange={(value) => updateField('overview', value)}
+ />
+
+ {/* Technology Stack */}
+ updateField('technology_stack', value)}
+ />
+
+ {/* Core Capabilities */}
+ updateField('core_capabilities', value)}
+ />
+
+ {/* Implemented Features */}
+ updateField('implemented_features', value)}
+ />
+
+ {/* Additional Requirements (Optional) */}
+
+ updateField('additional_requirements', value.length > 0 ? value : undefined)
+ }
+ />
+
+ {/* Development Guidelines (Optional) */}
+
+ updateField('development_guidelines', value.length > 0 ? value : undefined)
+ }
+ />
+
+ {/* Implementation Roadmap (Optional) */}
+
+ updateField('implementation_roadmap', value.length > 0 ? value : undefined)
+ }
+ />
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/spec-editor.tsx b/apps/ui/src/components/views/spec-view/components/spec-editor.tsx
index 3df2d6db..aafb568f 100644
--- a/apps/ui/src/components/views/spec-view/components/spec-editor.tsx
+++ b/apps/ui/src/components/views/spec-view/components/spec-editor.tsx
@@ -8,8 +8,8 @@ interface SpecEditorProps {
export function SpecEditor({ value, onChange }: SpecEditorProps) {
return (
-
-
+
+
@@ -64,7 +65,7 @@ export function SpecEmptyState({
{isCreating ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/views/spec-view/components/spec-header.tsx b/apps/ui/src/components/views/spec-view/components/spec-header.tsx
index b38a6579..4f0016ca 100644
--- a/apps/ui/src/components/views/spec-view/components/spec-header.tsx
+++ b/apps/ui/src/components/views/spec-view/components/spec-header.tsx
@@ -3,7 +3,8 @@ import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
-import { Save, Sparkles, Loader2, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react';
+import { Save, Sparkles, FileText, AlertCircle, ListPlus, RefreshCcw } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { PHASE_LABELS } from '../constants';
interface SpecHeaderProps {
@@ -22,6 +23,8 @@ interface SpecHeaderProps {
onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
+ // Mode-related props for save button visibility
+ showSaveButton: boolean;
}
export function SpecHeader({
@@ -40,6 +43,7 @@ export function SpecHeader({
onSaveClick,
showActionsPanel,
onToggleActionsPanel,
+ showSaveButton,
}: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
@@ -59,7 +63,7 @@ export function SpecHeader({
{isProcessing && (
@@ -83,7 +87,7 @@ export function SpecHeader({
{/* Mobile processing indicator */}
{isProcessing && (
-
+
Processing...
)}
@@ -132,15 +136,17 @@ export function SpecHeader({
Generate Features
-
-
- {isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
-
+ {showSaveButton && (
+
+
+ {isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
+
+ )}
)}
{/* Tablet/Mobile: show trigger for actions panel */}
@@ -157,7 +163,7 @@ export function SpecHeader({
{/* Status messages in panel */}
{isProcessing && (
-
+
{isSyncing
@@ -211,15 +217,17 @@ export function SpecHeader({
Generate Features
-
-
- {isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
-
+ {showSaveButton && (
+
+
+ {isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
+
+ )}
>
)}
diff --git a/apps/ui/src/components/views/spec-view/components/spec-mode-tabs.tsx b/apps/ui/src/components/views/spec-view/components/spec-mode-tabs.tsx
new file mode 100644
index 00000000..f57c07f0
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/spec-mode-tabs.tsx
@@ -0,0 +1,55 @@
+import { Eye, Edit3, Code } from 'lucide-react';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import type { SpecViewMode } from '../types';
+
+interface SpecModeTabsProps {
+ mode: SpecViewMode;
+ onModeChange: (mode: SpecViewMode) => void;
+ isParseValid: boolean;
+ disabled?: boolean;
+}
+
+export function SpecModeTabs({
+ mode,
+ onModeChange,
+ isParseValid,
+ disabled = false,
+}: SpecModeTabsProps) {
+ const handleValueChange = (value: string) => {
+ onModeChange(value as SpecViewMode);
+ };
+
+ return (
+
+
+
+
+ View
+
+
+
+ Edit
+
+
+
+ Source
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/components/spec-view-mode.tsx b/apps/ui/src/components/views/spec-view/components/spec-view-mode.tsx
new file mode 100644
index 00000000..29255334
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/components/spec-view-mode.tsx
@@ -0,0 +1,223 @@
+import type { SpecOutput } from '@automaker/spec-parser';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from '@/components/ui/accordion';
+import {
+ CheckCircle2,
+ Circle,
+ Clock,
+ Cpu,
+ FileCode2,
+ FolderOpen,
+ Lightbulb,
+ ListChecks,
+ Map as MapIcon,
+ ScrollText,
+ Wrench,
+} from 'lucide-react';
+
+interface SpecViewModeProps {
+ spec: SpecOutput;
+}
+
+function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'pending' }) {
+ const variants = {
+ completed: { variant: 'success' as const, icon: CheckCircle2, label: 'Completed' },
+ in_progress: { variant: 'warning' as const, icon: Clock, label: 'In Progress' },
+ pending: { variant: 'muted' as const, icon: Circle, label: 'Pending' },
+ };
+
+ const { variant, icon: Icon, label } = variants[status];
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function SpecViewMode({ spec }: SpecViewModeProps) {
+ return (
+
+
+ {/* Project Header */}
+
+
{spec.project_name}
+
{spec.overview}
+
+
+ {/* Technology Stack */}
+
+
+
+
+ Technology Stack
+
+
+
+
+ {spec.technology_stack.map((tech, index) => (
+
+ {tech}
+
+ ))}
+
+
+
+
+ {/* Core Capabilities */}
+
+
+
+
+ Core Capabilities
+
+
+
+
+ {spec.core_capabilities.map((capability, index) => (
+
+
+ {capability}
+
+ ))}
+
+
+
+
+ {/* Implemented Features */}
+ {spec.implemented_features.length > 0 && (
+
+
+
+
+ Implemented Features
+
+ {spec.implemented_features.length}
+
+
+
+
+
+ {spec.implemented_features.map((feature, index) => (
+
+
+
+
+ {feature.name}
+
+
+
+
+
{feature.description}
+ {feature.file_locations && feature.file_locations.length > 0 && (
+
+
+
+ File Locations:
+
+
+ {feature.file_locations.map((loc, locIndex) => (
+
+ {loc}
+
+ ))}
+
+
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Additional Requirements */}
+ {spec.additional_requirements && spec.additional_requirements.length > 0 && (
+
+
+
+
+ Additional Requirements
+
+
+
+
+ {spec.additional_requirements.map((req, index) => (
+
+
+ {req}
+
+ ))}
+
+
+
+ )}
+
+ {/* Development Guidelines */}
+ {spec.development_guidelines && spec.development_guidelines.length > 0 && (
+
+
+
+
+ Development Guidelines
+
+
+
+
+ {spec.development_guidelines.map((guideline, index) => (
+
+
+ {guideline}
+
+ ))}
+
+
+
+ )}
+
+ {/* Implementation Roadmap */}
+ {spec.implementation_roadmap && spec.implementation_roadmap.length > 0 && (
+
+
+
+
+ Implementation Roadmap
+
+
+
+
+ {spec.implementation_roadmap.map((phase, index) => (
+
+
+
+
+
+
{phase.phase}
+
{phase.description}
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx
index 73389f78..f77b08ca 100644
--- a/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx
+++ b/apps/ui/src/components/views/spec-view/dialogs/create-spec-dialog.tsx
@@ -1,4 +1,5 @@
-import { Sparkles, Clock, Loader2 } from 'lucide-react';
+import { Sparkles, Clock } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -163,7 +164,7 @@ export function CreateSpecDialog({
>
{isCreatingSpec ? (
<>
-
+
Generating...
>
) : (
diff --git a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx
index fd534a58..c911fc94 100644
--- a/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx
+++ b/apps/ui/src/components/views/spec-view/dialogs/regenerate-spec-dialog.tsx
@@ -1,4 +1,5 @@
-import { Sparkles, Clock, Loader2 } from 'lucide-react';
+import { Sparkles, Clock } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -158,7 +159,7 @@ export function RegenerateSpecDialog({
>
{isRegenerating ? (
<>
-
+
Regenerating...
>
) : (
diff --git a/apps/ui/src/components/views/spec-view/hooks/index.ts b/apps/ui/src/components/views/spec-view/hooks/index.ts
index 5e2309f8..330766f5 100644
--- a/apps/ui/src/components/views/spec-view/hooks/index.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/index.ts
@@ -1,3 +1,5 @@
export { useSpecLoading } from './use-spec-loading';
export { useSpecSave } from './use-spec-save';
export { useSpecGeneration } from './use-spec-generation';
+export { useSpecParser } from './use-spec-parser';
+export type { UseSpecParserResult } from './use-spec-parser';
diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-parser.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-parser.ts
new file mode 100644
index 00000000..ba6c0266
--- /dev/null
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-parser.ts
@@ -0,0 +1,61 @@
+import { useMemo } from 'react';
+import {
+ xmlToSpec,
+ isValidSpecXml,
+ type ParseResult,
+ type SpecOutput,
+} from '@automaker/spec-parser';
+
+/**
+ * Result of the spec parsing hook.
+ */
+export interface UseSpecParserResult {
+ /** Whether the XML is valid */
+ isValid: boolean;
+ /** The parsed spec object, or null if parsing failed */
+ parsedSpec: SpecOutput | null;
+ /** Parsing errors, if any */
+ errors: string[];
+ /** The full parse result */
+ parseResult: ParseResult | null;
+}
+
+/**
+ * Hook to parse XML spec content into a SpecOutput object.
+ * Memoizes the parsing result to avoid unnecessary re-parsing.
+ *
+ * @param xmlContent - The raw XML content from app_spec.txt
+ * @returns Parsed spec data with validation status
+ */
+export function useSpecParser(xmlContent: string): UseSpecParserResult {
+ return useMemo(() => {
+ if (!xmlContent || !xmlContent.trim()) {
+ return {
+ isValid: false,
+ parsedSpec: null,
+ errors: ['No spec content provided'],
+ parseResult: null,
+ };
+ }
+
+ // Quick structure check first
+ if (!isValidSpecXml(xmlContent)) {
+ return {
+ isValid: false,
+ parsedSpec: null,
+ errors: ['Invalid XML structure - missing required elements'],
+ parseResult: null,
+ };
+ }
+
+ // Full parse
+ const parseResult = xmlToSpec(xmlContent);
+
+ return {
+ isValid: parseResult.success,
+ parsedSpec: parseResult.spec,
+ errors: parseResult.errors,
+ parseResult,
+ };
+ }, [xmlContent]);
+}
diff --git a/apps/ui/src/components/views/spec-view/types.ts b/apps/ui/src/components/views/spec-view/types.ts
index 084909e9..0000b0d7 100644
--- a/apps/ui/src/components/views/spec-view/types.ts
+++ b/apps/ui/src/components/views/spec-view/types.ts
@@ -1,3 +1,6 @@
+// Spec view mode - determines how the spec is displayed/edited
+export type SpecViewMode = 'view' | 'edit' | 'source';
+
// Feature count options for spec generation
export type FeatureCount = 20 | 50 | 100;
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx
index 328afc21..df01e59f 100644
--- a/apps/ui/src/components/views/terminal-view.tsx
+++ b/apps/ui/src/components/views/terminal-view.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import {
Terminal as TerminalIcon,
@@ -7,13 +8,13 @@ import {
Unlock,
SplitSquareHorizontal,
SplitSquareVertical,
- Loader2,
AlertCircle,
RefreshCw,
X,
SquarePlus,
Settings,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getServerUrlSync } from '@/lib/http-api-client';
import {
useAppStore,
@@ -216,7 +217,18 @@ function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
);
}
-export function TerminalView() {
+interface TerminalViewProps {
+ /** Initial working directory to open a terminal in (e.g., from worktree panel) */
+ initialCwd?: string;
+ /** Branch name for display in toast (optional) */
+ initialBranch?: string;
+ /** Mode for opening terminal: 'tab' for new tab, 'split' for split in current tab */
+ initialMode?: 'tab' | 'split';
+ /** Unique nonce to allow opening the same worktree multiple times */
+ nonce?: number;
+}
+
+export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
const {
terminalState,
setTerminalUnlocked,
@@ -246,6 +258,8 @@ export function TerminalView() {
updateTerminalPanelSizes,
} = useAppStore();
+ const navigate = useNavigate();
+
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -264,6 +278,7 @@ export function TerminalView() {
max: number;
} | null>(null);
const hasShownHighRamWarningRef = useRef(false);
+ const initialCwdHandledRef = useRef(null);
// Show warning when 20+ terminals are open
useEffect(() => {
@@ -537,6 +552,106 @@ export function TerminalView() {
}
}, [terminalState.isUnlocked, fetchServerSettings]);
+ // Handle initialCwd prop - auto-create a terminal with the specified working directory
+ // This is triggered when navigating from worktree panel's "Open in Integrated Terminal"
+ useEffect(() => {
+ // Skip if no initialCwd provided
+ if (!initialCwd) return;
+
+ // Skip if we've already handled this exact request (prevents duplicate terminals)
+ // Include mode and nonce in the key to allow opening same cwd multiple times
+ const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
+ if (initialCwdHandledRef.current === cwdKey) return;
+
+ // Skip if terminal is not enabled or not unlocked
+ if (!status?.enabled) return;
+ if (status.passwordRequired && !terminalState.isUnlocked) return;
+
+ // Skip if still loading
+ if (loading) return;
+
+ // Mark this cwd as being handled
+ initialCwdHandledRef.current = cwdKey;
+
+ // Create the terminal with the specified cwd
+ const createTerminalWithCwd = async () => {
+ try {
+ const headers: Record = {};
+ if (terminalState.authToken) {
+ headers['X-Terminal-Token'] = terminalState.authToken;
+ }
+
+ const response = await apiFetch('/api/terminal/sessions', 'POST', {
+ headers,
+ body: { cwd: initialCwd, cols: 80, rows: 24 },
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ // Create in new tab or split based on mode
+ if (initialMode === 'tab') {
+ // Create in a new tab (tab name uses default "Terminal N" naming)
+ const newTabId = addTerminalTab();
+ const { addTerminalToTab } = useAppStore.getState();
+ // Pass branch name for display in terminal panel header
+ addTerminalToTab(data.data.id, newTabId, 'horizontal', initialBranch);
+ } else {
+ // Default: add to current tab (split if there's already a terminal)
+ // Pass branch name for display in terminal panel header
+ addTerminalToLayout(data.data.id, undefined, undefined, initialBranch);
+ }
+
+ // Mark this session as new for running initial command
+ if (defaultRunScript) {
+ setNewSessionIds((prev) => new Set(prev).add(data.data.id));
+ }
+
+ // Show success toast with branch name if provided
+ const displayName = initialBranch || initialCwd.split('/').pop() || initialCwd;
+ toast.success(`Terminal opened at ${displayName}`);
+
+ // Refresh session count
+ fetchServerSettings();
+
+ // Clear the cwd from the URL to prevent re-creating on refresh
+ navigate({ to: '/terminal', search: {}, replace: true });
+ } else {
+ logger.error('Failed to create terminal for cwd:', data.error);
+ toast.error('Failed to create terminal', {
+ description: data.error || 'Unknown error',
+ });
+ // Reset the handled ref so the same cwd can be retried
+ initialCwdHandledRef.current = undefined;
+ }
+ } catch (err) {
+ logger.error('Create terminal with cwd error:', err);
+ toast.error('Failed to create terminal', {
+ description: 'Could not connect to server',
+ });
+ // Reset the handled ref so the same cwd can be retried
+ initialCwdHandledRef.current = undefined;
+ }
+ };
+
+ createTerminalWithCwd();
+ }, [
+ initialCwd,
+ initialBranch,
+ initialMode,
+ nonce,
+ status?.enabled,
+ status?.passwordRequired,
+ terminalState.isUnlocked,
+ terminalState.authToken,
+ terminalState.tabs.length,
+ loading,
+ defaultRunScript,
+ addTerminalToLayout,
+ addTerminalTab,
+ fetchServerSettings,
+ navigate,
+ ]);
+
// Handle project switching - save and restore terminal layouts
// Uses terminalState.lastActiveProjectPath (persisted in store) instead of a local ref
// This ensures terminals persist when navigating away from terminal route and back
@@ -828,9 +943,11 @@ export function TerminalView() {
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
+ // customCwd: optional working directory to use instead of the current project path
const createTerminal = async (
direction?: 'horizontal' | 'vertical',
- targetSessionId?: string
+ targetSessionId?: string,
+ customCwd?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return;
@@ -844,7 +961,7 @@ export function TerminalView() {
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
- body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
+ body: { cwd: customCwd || currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -1232,6 +1349,7 @@ export function TerminalView() {
onCommandRan={() => handleCommandRan(content.sessionId)}
isMaximized={terminalState.maximizedSessionId === content.sessionId}
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
+ branchName={content.branchName}
/>
);
@@ -1279,7 +1397,7 @@ export function TerminalView() {
if (loading) {
return (
-
+
);
}
@@ -1342,7 +1460,7 @@ export function TerminalView() {
{authError && {authError}
}
{authLoading ? (
-
+
) : (
)}
diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
index 481ee6b4..ce6359c8 100644
--- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
+++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx
@@ -13,7 +13,6 @@ import {
CheckSquare,
Trash2,
ImageIcon,
- Loader2,
Settings,
RotateCcw,
Search,
@@ -22,8 +21,10 @@ import {
Maximize2,
Minimize2,
ArrowDown,
+ GitBranch,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
+import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Slider } from '@/components/ui/slider';
@@ -94,6 +95,7 @@ interface TerminalPanelProps {
onCommandRan?: () => void; // Callback when the initial command has been sent
isMaximized?: boolean;
onToggleMaximize?: () => void;
+ branchName?: string; // Branch name to display in header (from "Open in Terminal" action)
}
// Type for xterm Terminal - we'll use any since we're dynamically importing
@@ -124,6 +126,7 @@ export function TerminalPanel({
onCommandRan,
isMaximized = false,
onToggleMaximize,
+ branchName,
}: TerminalPanelProps) {
const terminalRef = useRef(null);
const containerRef = useRef(null);
@@ -1743,7 +1746,7 @@ export function TerminalPanel({
{isProcessingImage ? (
<>
-
+
Processing...
>
) : (
@@ -1776,6 +1779,13 @@ export function TerminalPanel({
{shellName}
+ {/* Branch name indicator - show when terminal was opened from worktree */}
+ {branchName && (
+
+
+ {branchName}
+
+ )}
{/* Font size indicator - only show when not default */}
{fontSize !== DEFAULT_FONT_SIZE && (
-
+
Reconnecting...
)}
diff --git a/apps/ui/src/components/views/welcome-view.tsx b/apps/ui/src/components/views/welcome-view.tsx
index b07c5188..bfe0d92a 100644
--- a/apps/ui/src/components/views/welcome-view.tsx
+++ b/apps/ui/src/components/views/welcome-view.tsx
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { useAppStore, type ThemeMode } from '@/store/app-store';
+import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import {
@@ -20,8 +20,8 @@ import {
Sparkles,
MessageSquare,
ChevronDown,
- Loader2,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import {
DropdownMenu,
DropdownMenuContent,
@@ -38,15 +38,7 @@ import { useNavigate } from '@tanstack/react-router';
const logger = createLogger('WelcomeView');
export function WelcomeView() {
- const {
- projects,
- trashedProjects,
- currentProject,
- upsertAndSetCurrentProject,
- addProject,
- setCurrentProject,
- theme: globalTheme,
- } = useAppStore();
+ const { projects, upsertAndSetCurrentProject, addProject, setCurrentProject } = useAppStore();
const navigate = useNavigate();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreating, setIsCreating] = useState(false);
@@ -109,13 +101,8 @@ export function WelcomeView() {
}
// Upsert project and set as current (handles both create and update cases)
- // Theme preservation is handled by the store action
- const trashedProject = trashedProjects.find((p) => p.path === path);
- const effectiveTheme =
- (trashedProject?.theme as ThemeMode | undefined) ||
- (currentProject?.theme as ThemeMode | undefined) ||
- globalTheme;
- upsertAndSetCurrentProject(path, name, effectiveTheme);
+ // Theme handling (trashed project recovery or undefined for global) is done by the store
+ upsertAndSetCurrentProject(path, name);
// Show initialization dialog if files were created
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
@@ -150,14 +137,7 @@ export function WelcomeView() {
setIsOpening(false);
}
},
- [
- trashedProjects,
- currentProject,
- globalTheme,
- upsertAndSetCurrentProject,
- analyzeProject,
- navigate,
- ]
+ [upsertAndSetCurrentProject, analyzeProject, navigate]
);
const handleOpenProject = useCallback(async () => {
@@ -758,7 +738,7 @@ export function WelcomeView() {
{isAnalyzing ? (
-
+
AI agent is analyzing your project structure...
@@ -802,7 +782,7 @@ export function WelcomeView() {
data-testid="project-opening-overlay"
>
-
+
Initializing project...
diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts
index fe23582f..8175b16a 100644
--- a/apps/ui/src/hooks/use-auto-mode.ts
+++ b/apps/ui/src/hooks/use-auto-mode.ts
@@ -94,21 +94,33 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
- // Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
- // This is intentionally session-scoped to avoid auto-running features after a full app restart.
+ // On mount, query backend for current auto loop status and sync UI state.
+ // This handles cases where the backend is still running after a page refresh.
useEffect(() => {
if (!currentProject) return;
- const session = readAutoModeSession();
- const desired = session[currentProject.path];
- if (typeof desired !== 'boolean') return;
+ const syncWithBackend = async () => {
+ try {
+ const api = getElectronAPI();
+ if (!api?.autoMode?.status) return;
- if (desired !== isAutoModeRunning) {
- logger.info(
- `[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
- );
- setAutoModeRunning(currentProject.id, desired);
- }
+ const result = await api.autoMode.status(currentProject.path);
+ if (result.success && result.isAutoLoopRunning !== undefined) {
+ const backendIsRunning = result.isAutoLoopRunning;
+ if (backendIsRunning !== isAutoModeRunning) {
+ logger.info(
+ `[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
+ );
+ setAutoModeRunning(currentProject.id, backendIsRunning);
+ setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
+ }
+ }
+ } catch (error) {
+ logger.error('Error syncing auto mode state with backend:', error);
+ }
+ };
+
+ syncWithBackend();
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects
@@ -139,6 +151,22 @@ export function useAutoMode() {
}
switch (event.type) {
+ case 'auto_mode_started':
+ // Backend started auto loop - update UI state
+ logger.info('[AutoMode] Backend started auto loop for project');
+ if (eventProjectId) {
+ setAutoModeRunning(eventProjectId, true);
+ }
+ break;
+
+ case 'auto_mode_stopped':
+ // Backend stopped auto loop - update UI state
+ logger.info('[AutoMode] Backend stopped auto loop for project');
+ if (eventProjectId) {
+ setAutoModeRunning(eventProjectId, false);
+ }
+ break;
+
case 'auto_mode_feature_start':
if (event.featureId) {
addRunningTask(eventProjectId, event.featureId);
@@ -374,35 +402,92 @@ export function useAutoMode() {
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
+ setAutoModeRunning,
currentProject?.path,
]);
- // Start auto mode - UI only, feature pickup is handled in board-view.tsx
- const start = useCallback(() => {
+ // Start auto mode - calls backend to start the auto loop
+ const start = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
- setAutoModeSessionForProjectPath(currentProject.path, true);
- setAutoModeRunning(currentProject.id, true);
- logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
+ try {
+ const api = getElectronAPI();
+ if (!api?.autoMode?.start) {
+ throw new Error('Start auto mode API not available');
+ }
+
+ logger.info(
+ `[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
+ );
+
+ // Optimistically update UI state (backend will confirm via event)
+ setAutoModeSessionForProjectPath(currentProject.path, true);
+ setAutoModeRunning(currentProject.id, true);
+
+ // Call backend to start the auto loop
+ const result = await api.autoMode.start(currentProject.path, maxConcurrency);
+
+ if (!result.success) {
+ // Revert UI state on failure
+ setAutoModeSessionForProjectPath(currentProject.path, false);
+ setAutoModeRunning(currentProject.id, false);
+ logger.error('Failed to start auto mode:', result.error);
+ throw new Error(result.error || 'Failed to start auto mode');
+ }
+
+ logger.debug(`[AutoMode] Started successfully`);
+ } catch (error) {
+ // Revert UI state on error
+ setAutoModeSessionForProjectPath(currentProject.path, false);
+ setAutoModeRunning(currentProject.id, false);
+ logger.error('Error starting auto mode:', error);
+ throw error;
+ }
}, [currentProject, setAutoModeRunning, maxConcurrency]);
- // Stop auto mode - UI only, running tasks continue until natural completion
- const stop = useCallback(() => {
+ // Stop auto mode - calls backend to stop the auto loop
+ const stop = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
- setAutoModeSessionForProjectPath(currentProject.path, false);
- setAutoModeRunning(currentProject.id, false);
- // NOTE: We intentionally do NOT clear running tasks here.
- // Stopping auto mode only turns off the toggle to prevent new features
- // from being picked up. Running tasks will complete naturally and be
- // removed via the auto_mode_feature_complete event.
- logger.info('Stopped - running tasks will continue');
+ try {
+ const api = getElectronAPI();
+ if (!api?.autoMode?.stop) {
+ throw new Error('Stop auto mode API not available');
+ }
+
+ logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
+
+ // Optimistically update UI state (backend will confirm via event)
+ setAutoModeSessionForProjectPath(currentProject.path, false);
+ setAutoModeRunning(currentProject.id, false);
+
+ // Call backend to stop the auto loop
+ const result = await api.autoMode.stop(currentProject.path);
+
+ if (!result.success) {
+ // Revert UI state on failure
+ setAutoModeSessionForProjectPath(currentProject.path, true);
+ setAutoModeRunning(currentProject.id, true);
+ logger.error('Failed to stop auto mode:', result.error);
+ throw new Error(result.error || 'Failed to stop auto mode');
+ }
+
+ // NOTE: Running tasks will continue until natural completion.
+ // The backend stops picking up new features but doesn't abort running ones.
+ logger.info('Stopped - running tasks will continue');
+ } catch (error) {
+ // Revert UI state on error
+ setAutoModeSessionForProjectPath(currentProject.path, true);
+ setAutoModeRunning(currentProject.id, true);
+ logger.error('Error stopping auto mode:', error);
+ throw error;
+ }
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts
index 07119b85..58b3ec2d 100644
--- a/apps/ui/src/hooks/use-settings-migration.ts
+++ b/apps/ui/src/hooks/use-settings-migration.ts
@@ -31,7 +31,11 @@ import { useSetupStore } from '@/store/setup-store';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
+ getAllCursorModelIds,
+ migrateCursorModelIds,
+ migratePhaseModelEntry,
type GlobalSettings,
+ type CursorModelId,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -111,9 +115,34 @@ export function resetMigrationState(): void {
/**
* Parse localStorage data into settings object
+ *
+ * Checks for settings in multiple locations:
+ * 1. automaker-settings-cache: Fresh server settings cached from last fetch
+ * 2. automaker-storage: Zustand-persisted app store state (legacy)
+ * 3. automaker-setup: Setup wizard state (legacy)
+ * 4. Standalone keys: worktree-panel-collapsed, file-browser-recent-folders, etc.
+ *
+ * @returns Merged settings object or null if no settings found
*/
export function parseLocalStorageSettings(): Partial
| null {
try {
+ // First, check for fresh server settings cache (updated whenever server settings are fetched)
+ // This prevents stale data when switching between modes
+ const settingsCache = getItem('automaker-settings-cache');
+ if (settingsCache) {
+ try {
+ const cached = JSON.parse(settingsCache) as GlobalSettings;
+ const cacheProjectCount = cached?.projects?.length ?? 0;
+ logger.info(`[CACHE_LOADED] projects=${cacheProjectCount}, theme=${cached?.theme}`);
+ return cached;
+ } catch (e) {
+ logger.warn('Failed to parse settings cache, falling back to old storage');
+ }
+ } else {
+ logger.info('[CACHE_EMPTY] No settings cache found in localStorage');
+ }
+
+ // Fall back to old Zustand persisted storage
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return null;
@@ -186,7 +215,14 @@ export function parseLocalStorageSettings(): Partial | null {
/**
* Check if localStorage has more complete data than server
- * Returns true if localStorage has projects but server doesn't
+ *
+ * Compares the completeness of data to determine if a migration is needed.
+ * Returns true if localStorage has projects but server doesn't, indicating
+ * the localStorage data should be merged with server settings.
+ *
+ * @param localSettings Settings loaded from localStorage
+ * @param serverSettings Settings loaded from server
+ * @returns true if localStorage has more data that should be preserved
*/
export function localStorageHasMoreData(
localSettings: Partial | null,
@@ -209,7 +245,15 @@ export function localStorageHasMoreData(
/**
* Merge localStorage settings with server settings
- * Prefers server data, but uses localStorage for missing arrays/objects
+ *
+ * Intelligently combines settings from both sources:
+ * - Prefers server data as the base
+ * - Uses localStorage values when server has empty arrays/objects
+ * - Specific handling for: projects, trashedProjects, mcpServers, recentFolders, etc.
+ *
+ * @param serverSettings Settings from server API (base)
+ * @param localSettings Settings from localStorage (fallback)
+ * @returns Merged GlobalSettings object ready to hydrate the store
*/
export function mergeSettings(
serverSettings: GlobalSettings,
@@ -291,20 +335,33 @@ export function mergeSettings(
* This is the core migration logic extracted for use outside of React hooks.
* Call this from __root.tsx during app initialization.
*
- * @param serverSettings - Settings fetched from the server API
- * @returns Promise resolving to the final settings to use (merged if migration needed)
+ * Flow:
+ * 1. If server has localStorageMigrated flag, skip migration (already done)
+ * 2. Check if localStorage has more data than server
+ * 3. If yes, merge them and sync merged state back to server
+ * 4. Set localStorageMigrated flag to prevent re-migration
+ *
+ * @param serverSettings Settings fetched from the server API
+ * @returns Promise resolving to {settings, migrated} - final settings and whether migration occurred
*/
export async function performSettingsMigration(
serverSettings: GlobalSettings
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
// Get localStorage data
const localSettings = parseLocalStorageSettings();
- logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
- logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
+ const localProjects = localSettings?.projects?.length ?? 0;
+ const serverProjects = serverSettings.projects?.length ?? 0;
+
+ logger.info('[MIGRATION_CHECK]', {
+ localStorageProjects: localProjects,
+ serverProjects: serverProjects,
+ localStorageMigrated: serverSettings.localStorageMigrated,
+ dataSourceMismatch: localProjects !== serverProjects,
+ });
// Check if migration has already been completed
if (serverSettings.localStorageMigrated) {
- logger.info('localStorage migration already completed, using server settings only');
+ logger.info('[MIGRATION_SKIP] Using server settings only (migration already completed)');
return { settings: serverSettings, migrated: false };
}
@@ -412,6 +469,15 @@ export function useSettingsMigration(): MigrationState {
if (global.success && global.settings) {
serverSettings = global.settings as unknown as GlobalSettings;
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
+
+ // Update localStorage with fresh server data to keep cache in sync
+ // This prevents stale localStorage data from being used when switching between modes
+ try {
+ setItem('automaker-settings-cache', JSON.stringify(serverSettings));
+ logger.debug('Updated localStorage with fresh server settings');
+ } catch (storageError) {
+ logger.warn('Failed to update localStorage cache:', storageError);
+ }
}
} catch (error) {
logger.error('Failed to fetch server settings:', error);
@@ -504,6 +570,19 @@ export function useSettingsMigration(): MigrationState {
*/
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
+
+ // Migrate Cursor models to canonical format
+ // IMPORTANT: Always use ALL available Cursor models to ensure new models are visible
+ // Users who had old settings with a subset of models should still see all available models
+ const allCursorModels = getAllCursorModelIds();
+ const migratedCursorDefault = migrateCursorModelIds([
+ settings.cursorDefaultModel ?? current.cursorDefaultModel ?? 'cursor-auto',
+ ])[0];
+ const validCursorModelIds = new Set(allCursorModels);
+ const sanitizedCursorDefaultModel = validCursorModelIds.has(migratedCursorDefault)
+ ? migratedCursorDefault
+ : ('cursor-auto' as CursorModelId);
+
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
@@ -569,15 +648,17 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
useWorktrees: settings.useWorktrees ?? true,
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
- defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
+ defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
+ model: 'claude-opus',
+ },
muteDoneSound: settings.muteDoneSound ?? false,
serverLogLevel: settings.serverLogLevel ?? 'info',
enableRequestLogging: settings.enableRequestLogging ?? true,
- enhancementModel: settings.enhancementModel ?? 'sonnet',
- validationModel: settings.validationModel ?? 'opus',
+ enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
+ validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: settings.phaseModels ?? current.phaseModels,
- enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
- cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
+ enabledCursorModels: allCursorModels, // Always use ALL cursor models
+ cursorDefaultModel: sanitizedCursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts
index 0648a3e6..c2b48ae7 100644
--- a/apps/ui/src/hooks/use-settings-sync.ts
+++ b/apps/ui/src/hooks/use-settings-sync.ts
@@ -22,7 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
+ getAllCursorModelIds,
+ migrateCursorModelIds,
+ migrateOpencodeModelIds,
+ migratePhaseModelEntry,
type GlobalSettings,
+ type CursorModelId,
+ type OpencodeModelId,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -36,6 +42,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'fontFamilySans',
'fontFamilyMono',
'terminalFontFamily', // Maps to terminalState.fontFamily
+ 'openTerminalMode', // Maps to terminalState.openTerminalMode
'sidebarOpen',
'chatHistoryOpen',
'maxConcurrency',
@@ -62,6 +69,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'keyboardShortcuts',
'mcpServers',
'defaultEditorCommand',
+ 'defaultTerminalId',
'promptCustomization',
'eventHooks',
'claudeApiProfiles',
@@ -83,7 +91,15 @@ const SETUP_FIELDS_TO_SYNC = ['isFirstRun', 'setupComplete', 'skipClaudeSetup']
/**
* Helper to extract a settings field value from app state
- * Handles special cases for nested/mapped fields
+ *
+ * Handles special cases where store fields don't map directly to settings:
+ * - currentProjectId: Extract from currentProject?.id
+ * - terminalFontFamily: Extract from terminalState.fontFamily
+ * - Other fields: Direct access
+ *
+ * @param field The settings field to extract
+ * @param appState Current app store state
+ * @returns The value of the field in the app state
*/
function getSettingsFieldValue(
field: (typeof SETTINGS_FIELDS_TO_SYNC)[number],
@@ -95,11 +111,24 @@ function getSettingsFieldValue(
if (field === 'terminalFontFamily') {
return appState.terminalState.fontFamily;
}
+ if (field === 'openTerminalMode') {
+ return appState.terminalState.openTerminalMode;
+ }
return appState[field as keyof typeof appState];
}
/**
* Helper to check if a settings field changed between states
+ *
+ * Compares field values between old and new state, handling special cases:
+ * - currentProjectId: Compare currentProject?.id values
+ * - terminalFontFamily: Compare terminalState.fontFamily values
+ * - Other fields: Direct reference equality check
+ *
+ * @param field The settings field to check
+ * @param newState New app store state
+ * @param prevState Previous app store state
+ * @returns true if the field value changed between states
*/
function hasSettingsFieldChanged(
field: (typeof SETTINGS_FIELDS_TO_SYNC)[number],
@@ -112,6 +141,9 @@ function hasSettingsFieldChanged(
if (field === 'terminalFontFamily') {
return newState.terminalState.fontFamily !== prevState.terminalState.fontFamily;
}
+ if (field === 'openTerminalMode') {
+ return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
+ }
const key = field as keyof typeof newState;
return newState[key] !== prevState[key];
}
@@ -174,14 +206,18 @@ export function useSettingsSync(): SettingsSyncState {
// Never sync when not authenticated or settings not loaded
// The settingsLoaded flag ensures we don't sync default empty state before hydration
const auth = useAuthStore.getState();
- logger.debug('syncToServer check:', {
+ logger.debug('[SYNC_CHECK] Auth state:', {
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
projectsCount: useAppStore.getState().projects?.length ?? 0,
});
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
- logger.debug('Sync skipped: not authenticated or settings not loaded');
+ logger.warn('[SYNC_SKIPPED] Not ready:', {
+ authChecked: auth.authChecked,
+ isAuthenticated: auth.isAuthenticated,
+ settingsLoaded: auth.settingsLoaded,
+ });
return;
}
@@ -189,7 +225,9 @@ export function useSettingsSync(): SettingsSyncState {
const api = getHttpApiClient();
const appState = useAppStore.getState();
- logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
+ logger.info('[SYNC_START] Syncing to server:', {
+ projectsCount: appState.projects?.length ?? 0,
+ });
// Build updates object from current state
const updates: Record = {};
@@ -206,17 +244,30 @@ export function useSettingsSync(): SettingsSyncState {
// Create a hash of the updates to avoid redundant syncs
const updateHash = JSON.stringify(updates);
if (updateHash === lastSyncedRef.current) {
- logger.debug('Sync skipped: no changes');
+ logger.debug('[SYNC_SKIP_IDENTICAL] No changes from last sync');
setState((s) => ({ ...s, syncing: false }));
return;
}
- logger.info('Sending settings update:', { projects: updates.projects });
+ logger.info('[SYNC_SEND] Sending settings update to server:', {
+ projects: (updates.projects as any)?.length ?? 0,
+ trashedProjects: (updates.trashedProjects as any)?.length ?? 0,
+ });
const result = await api.settings.updateGlobal(updates);
+ logger.info('[SYNC_RESPONSE] Server response:', { success: result.success });
if (result.success) {
lastSyncedRef.current = updateHash;
logger.debug('Settings synced to server');
+
+ // Update localStorage cache with synced settings to keep it fresh
+ // This prevents stale data when switching between Electron and web modes
+ try {
+ setItem('automaker-settings-cache', JSON.stringify(updates));
+ logger.debug('Updated localStorage cache after sync');
+ } catch (storageError) {
+ logger.warn('Failed to update localStorage cache after sync:', storageError);
+ }
} else {
logger.error('Failed to sync settings:', result.error);
}
@@ -342,9 +393,24 @@ export function useSettingsSync(): SettingsSyncState {
return;
}
- // Check if any synced field changed
+ // If projects array changed (by reference, meaning content changed), sync immediately
+ // This is critical - projects list changes must sync right away to prevent loss
+ // when switching between Electron and web modes or closing the app
+ if (newState.projects !== prevState.projects) {
+ logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
+ prevCount: prevState.projects?.length ?? 0,
+ newCount: newState.projects?.length ?? 0,
+ prevProjects: prevState.projects?.map((p) => p.name) ?? [],
+ newProjects: newState.projects?.map((p) => p.name) ?? [],
+ });
+ syncNow();
+ return;
+ }
+
+ // Check if any other synced field changed
let changed = false;
for (const field of SETTINGS_FIELDS_TO_SYNC) {
+ if (field === 'projects') continue; // Already handled above
if (hasSettingsFieldChanged(field, newState, prevState)) {
changed = true;
break;
@@ -451,17 +517,35 @@ export async function refreshSettingsFromServer(): Promise {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
- const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
- const incomingEnabledOpencodeModels =
- serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
- const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
- serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
- )
- ? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
- : DEFAULT_OPENCODE_MODEL;
- const sanitizedEnabledOpencodeModels = Array.from(
- new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
+
+ // Cursor models - ALWAYS use ALL available models to ensure new models are visible
+ const allCursorModels = getAllCursorModelIds();
+ const validCursorModelIds = new Set(allCursorModels);
+
+ // Migrate Cursor default model
+ const migratedCursorDefault = migrateCursorModelIds([
+ serverSettings.cursorDefaultModel ?? 'cursor-auto',
+ ])[0];
+ const sanitizedCursorDefault = validCursorModelIds.has(migratedCursorDefault)
+ ? migratedCursorDefault
+ : ('cursor-auto' as CursorModelId);
+
+ // Migrate OpenCode models to canonical format
+ const migratedOpencodeModels = migrateOpencodeModelIds(
+ serverSettings.enabledOpencodeModels ?? []
);
+ const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
+ const sanitizedEnabledOpencodeModels = migratedOpencodeModels.filter((id) =>
+ validOpencodeModelIds.has(id)
+ );
+
+ // Migrate OpenCode default model
+ const migratedOpencodeDefault = migrateOpencodeModelIds([
+ serverSettings.opencodeDefaultModel ?? DEFAULT_OPENCODE_MODEL,
+ ])[0];
+ const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(migratedOpencodeDefault)
+ ? migratedOpencodeDefault
+ : DEFAULT_OPENCODE_MODEL;
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
@@ -473,6 +557,37 @@ export async function refreshSettingsFromServer(): Promise {
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
+ // Migrate phase models to canonical format
+ const migratedPhaseModels = serverSettings.phaseModels
+ ? {
+ enhancementModel: migratePhaseModelEntry(serverSettings.phaseModels.enhancementModel),
+ fileDescriptionModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.fileDescriptionModel
+ ),
+ imageDescriptionModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.imageDescriptionModel
+ ),
+ validationModel: migratePhaseModelEntry(serverSettings.phaseModels.validationModel),
+ specGenerationModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.specGenerationModel
+ ),
+ featureGenerationModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.featureGenerationModel
+ ),
+ backlogPlanningModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.backlogPlanningModel
+ ),
+ projectAnalysisModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.projectAnalysisModel
+ ),
+ suggestionsModel: migratePhaseModelEntry(serverSettings.phaseModels.suggestionsModel),
+ memoryExtractionModel: migratePhaseModelEntry(
+ serverSettings.phaseModels.memoryExtractionModel
+ ),
+ commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
+ }
+ : undefined;
+
// Save theme to localStorage for fallback when server settings aren't available
if (serverSettings.theme) {
setItem(THEME_STORAGE_KEY, serverSettings.theme);
@@ -489,15 +604,17 @@ export async function refreshSettingsFromServer(): Promise {
useWorktrees: serverSettings.useWorktrees,
defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
- defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
+ defaultFeatureModel: serverSettings.defaultFeatureModel
+ ? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
+ : { model: 'claude-opus' },
muteDoneSound: serverSettings.muteDoneSound,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel,
- phaseModels: serverSettings.phaseModels,
- enabledCursorModels: serverSettings.enabledCursorModels,
- cursorDefaultModel: serverSettings.cursorDefaultModel,
+ phaseModels: migratedPhaseModels ?? serverSettings.phaseModels,
+ enabledCursorModels: allCursorModels, // Always use ALL cursor models
+ cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
@@ -511,6 +628,7 @@ export async function refreshSettingsFromServer(): Promise {
},
mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
+ defaultTerminalId: serverSettings.defaultTerminalId ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],
activeClaudeApiProfileId: serverSettings.activeClaudeApiProfileId ?? null,
@@ -523,11 +641,16 @@ export async function refreshSettingsFromServer(): Promise {
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '',
recentFolders: serverSettings.recentFolders ?? [],
- // Terminal font (nested in terminalState)
- ...(serverSettings.terminalFontFamily && {
+ // Terminal settings (nested in terminalState)
+ ...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
terminalState: {
...currentAppState.terminalState,
- fontFamily: serverSettings.terminalFontFamily,
+ ...(serverSettings.terminalFontFamily && {
+ fontFamily: serverSettings.terminalFontFamily,
+ }),
+ ...(serverSettings.openTerminalMode && {
+ openTerminalMode: serverSettings.openTerminalMode,
+ }),
},
}),
});
diff --git a/apps/ui/src/lib/api-fetch.ts b/apps/ui/src/lib/api-fetch.ts
index b544c993..f8959c8f 100644
--- a/apps/ui/src/lib/api-fetch.ts
+++ b/apps/ui/src/lib/api-fetch.ts
@@ -185,7 +185,13 @@ export function getAuthenticatedImageUrl(
if (apiKey) {
params.set('apiKey', apiKey);
}
- // Note: Session token auth relies on cookies which are sent automatically by the browser
+
+ // Web mode: also add session token as query param for image loads
+ // This ensures images load correctly even if cookies aren't sent (e.g., cross-origin proxy scenarios)
+ const sessionToken = getSessionToken();
+ if (sessionToken) {
+ params.set('token', sessionToken);
+ }
return `${serverUrl}/api/fs/image?${params.toString()}`;
}
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index f6eb6f2e..9c834955 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -495,10 +495,12 @@ export interface AutoModeAPI {
status: (projectPath?: string) => Promise<{
success: boolean;
isRunning?: boolean;
+ isAutoLoopRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
+ maxConcurrency?: number;
error?: string;
}>;
runFeature: (
@@ -1852,6 +1854,56 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
+ getAvailableTerminals: async () => {
+ console.log('[Mock] Getting available terminals');
+ return {
+ success: true,
+ result: {
+ terminals: [
+ { id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
+ { id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
+ ],
+ },
+ };
+ },
+
+ getDefaultTerminal: async () => {
+ console.log('[Mock] Getting default terminal');
+ return {
+ success: true,
+ result: {
+ terminalId: 'iterm2',
+ terminalName: 'iTerm2',
+ terminalCommand: 'open -a iTerm',
+ },
+ };
+ },
+
+ refreshTerminals: async () => {
+ console.log('[Mock] Refreshing available terminals');
+ return {
+ success: true,
+ result: {
+ terminals: [
+ { id: 'iterm2', name: 'iTerm2', command: 'open -a iTerm' },
+ { id: 'terminal-macos', name: 'Terminal', command: 'open -a Terminal' },
+ ],
+ message: 'Found 2 available terminals',
+ },
+ };
+ },
+
+ openInExternalTerminal: async (worktreePath: string, terminalId?: string) => {
+ console.log('[Mock] Opening in external terminal:', worktreePath, terminalId);
+ return {
+ success: true,
+ result: {
+ message: `Opened ${worktreePath} in ${terminalId ?? 'default terminal'}`,
+ terminalName: terminalId ?? 'Terminal',
+ },
+ };
+ },
+
initGit: async (projectPath: string) => {
console.log('[Mock] Initializing git:', projectPath);
return {
@@ -3226,7 +3278,7 @@ function createMockGitHubAPI(): GitHubAPI {
estimatedComplexity: 'moderate' as const,
},
projectPath,
- model: model || 'sonnet',
+ model: model || 'claude-sonnet',
})
);
}, 2000);
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index cd0e6739..ba2b8dd3 100644
--- a/apps/ui/src/lib/http-api-client.ts
+++ b/apps/ui/src/lib/http-api-client.ts
@@ -156,6 +156,12 @@ const getServerUrl = (): string => {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
+
+ // In web mode (not Electron), use relative URL to leverage Vite proxy
+ // This avoids CORS issues since requests appear same-origin
+ if (!window.electron) {
+ return '';
+ }
}
// Use VITE_HOSTNAME if set, otherwise default to localhost
const hostname = import.meta.env.VITE_HOSTNAME || 'localhost';
@@ -173,8 +179,24 @@ let apiKeyInitialized = false;
let apiKeyInitPromise: Promise | null = null;
// Cached session token for authentication (Web mode - explicit header auth)
-// Only used in-memory after fresh login; on refresh we rely on HTTP-only cookies
+// Persisted to localStorage to survive page reloads
let cachedSessionToken: string | null = null;
+const SESSION_TOKEN_KEY = 'automaker:sessionToken';
+
+// Initialize cached session token from localStorage on module load
+// This ensures web mode survives page reloads with valid authentication
+const initSessionToken = (): void => {
+ if (typeof window === 'undefined') return; // Skip in SSR
+ try {
+ cachedSessionToken = window.localStorage.getItem(SESSION_TOKEN_KEY);
+ } catch {
+ // localStorage might be disabled or unavailable
+ cachedSessionToken = null;
+ }
+};
+
+// Initialize on module load
+initSessionToken();
// Get API key for Electron mode (returns cached value after initialization)
// Exported for use in WebSocket connections that need auth
@@ -194,14 +216,30 @@ export const waitForApiKeyInit = (): Promise => {
// Get session token for Web mode (returns cached value after login)
export const getSessionToken = (): string | null => cachedSessionToken;
-// Set session token (called after login)
+// Set session token (called after login) - persists to localStorage for page reload survival
export const setSessionToken = (token: string | null): void => {
cachedSessionToken = token;
+ if (typeof window === 'undefined') return; // Skip in SSR
+ try {
+ if (token) {
+ window.localStorage.setItem(SESSION_TOKEN_KEY, token);
+ } else {
+ window.localStorage.removeItem(SESSION_TOKEN_KEY);
+ }
+ } catch {
+ // localStorage might be disabled; continue with in-memory cache
+ }
};
// Clear session token (called on logout)
export const clearSessionToken = (): void => {
cachedSessionToken = null;
+ if (typeof window === 'undefined') return; // Skip in SSR
+ try {
+ window.localStorage.removeItem(SESSION_TOKEN_KEY);
+ } catch {
+ // localStorage might be disabled
+ }
};
/**
@@ -1770,6 +1808,11 @@ export class HttpApiClient implements ElectronAPI {
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
+ getAvailableTerminals: () => this.get('/api/worktree/available-terminals'),
+ getDefaultTerminal: () => this.get('/api/worktree/default-terminal'),
+ refreshTerminals: () => this.post('/api/worktree/refresh-terminals', {}),
+ openInExternalTerminal: (worktreePath: string, terminalId?: string) =>
+ this.post('/api/worktree/open-in-external-terminal', { worktreePath, terminalId }),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts
index 27da4859..933ea1fd 100644
--- a/apps/ui/src/lib/utils.ts
+++ b/apps/ui/src/lib/utils.ts
@@ -125,6 +125,34 @@ export const isMac =
(/Mac/.test(navigator.userAgent) ||
(navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false));
+/**
+ * Sanitize a string for use in data-testid attributes.
+ * Creates a deterministic, URL-safe identifier from any input string.
+ *
+ * Transformations:
+ * - Convert to lowercase
+ * - Replace spaces with hyphens
+ * - Remove all non-alphanumeric characters (except hyphens)
+ * - Collapse multiple consecutive hyphens into a single hyphen
+ * - Trim leading/trailing hyphens
+ *
+ * @param name - The string to sanitize (e.g., project name, feature title)
+ * @returns A sanitized string safe for CSS selectors and test IDs
+ *
+ * @example
+ * sanitizeForTestId("My Awesome Project!") // "my-awesome-project"
+ * sanitizeForTestId("test-project-123") // "test-project-123"
+ * sanitizeForTestId(" Foo Bar ") // "foo-bar"
+ */
+export function sanitizeForTestId(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
/**
* Generate a UUID v4 string.
*
diff --git a/apps/ui/src/main.ts b/apps/ui/src/main.ts
index 8930d664..4d093106 100644
--- a/apps/ui/src/main.ts
+++ b/apps/ui/src/main.ts
@@ -474,6 +474,17 @@ async function startServer(): Promise {
? path.join(process.resourcesPath, 'server')
: path.join(__dirname, '../../server');
+ // IMPORTANT: Use shared data directory (not Electron's user data directory)
+ // This ensures Electron and web mode share the same settings/projects
+ // In dev: project root/data (navigate from __dirname which is apps/server/dist or apps/ui/dist-electron)
+ // In production: same as Electron user data (for app isolation)
+ const dataDir = app.isPackaged
+ ? app.getPath('userData')
+ : path.join(__dirname, '../../..', 'data');
+ logger.info(
+ `[DATA_DIR] app.isPackaged=${app.isPackaged}, __dirname=${__dirname}, dataDir=${dataDir}`
+ );
+
// Build enhanced PATH that includes Node.js directory (cross-platform)
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
if (enhancedPath !== process.env.PATH) {
@@ -484,7 +495,7 @@ async function startServer(): Promise {
...process.env,
PATH: enhancedPath,
PORT: serverPort.toString(),
- DATA_DIR: app.getPath('userData'),
+ DATA_DIR: dataDir,
NODE_PATH: serverNodeModules,
// Pass API key to server for CSRF protection
AUTOMAKER_API_KEY: apiKey!,
@@ -496,6 +507,7 @@ async function startServer(): Promise {
};
logger.info('Server will use port', serverPort);
+ logger.info('[DATA_DIR_SPAWN] env.DATA_DIR=', env.DATA_DIR);
logger.info('Starting backend server...');
logger.info('Server path:', serverPath);
@@ -647,20 +659,44 @@ function createWindow(): void {
// App lifecycle
app.whenReady().then(async () => {
- // Ensure userData path is consistent across dev/prod so files land in Automaker dir
- try {
- const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
- if (app.getPath('userData') !== desiredUserDataPath) {
- app.setPath('userData', desiredUserDataPath);
- logger.info('userData path set to:', desiredUserDataPath);
+ // In production, use Automaker dir in appData for app isolation
+ // In development, use project root for shared data between Electron and web mode
+ let userDataPathToUse: string;
+
+ if (app.isPackaged) {
+ // Production: Ensure userData path is consistent so files land in Automaker dir
+ try {
+ const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
+ if (app.getPath('userData') !== desiredUserDataPath) {
+ app.setPath('userData', desiredUserDataPath);
+ logger.info('[PRODUCTION] userData path set to:', desiredUserDataPath);
+ }
+ userDataPathToUse = desiredUserDataPath;
+ } catch (error) {
+ logger.warn('[PRODUCTION] Failed to set userData path:', (error as Error).message);
+ userDataPathToUse = app.getPath('userData');
+ }
+ } else {
+ // Development: Explicitly set userData to project root for shared data between Electron and web
+ // This OVERRIDES Electron's default userData path (~/.config/Automaker)
+ // __dirname is apps/ui/dist-electron, so go up to get project root
+ const projectRoot = path.join(__dirname, '../../..');
+ userDataPathToUse = path.join(projectRoot, 'data');
+ try {
+ app.setPath('userData', userDataPathToUse);
+ logger.info('[DEVELOPMENT] userData path explicitly set to:', userDataPathToUse);
+ } catch (error) {
+ logger.warn(
+ '[DEVELOPMENT] Failed to set userData path, using fallback:',
+ (error as Error).message
+ );
+ userDataPathToUse = path.join(projectRoot, 'data');
}
- } catch (error) {
- logger.warn('Failed to set userData path:', (error as Error).message);
}
// Initialize centralized path helpers for Electron
// This must be done before any file operations
- setElectronUserDataPath(app.getPath('userData'));
+ setElectronUserDataPath(userDataPathToUse);
// In development mode, allow access to the entire project root (for source files, node_modules, etc.)
// In production, only allow access to the built app directory and resources
@@ -675,7 +711,12 @@ app.whenReady().then(async () => {
// Initialize security settings for path validation
// Set DATA_DIR before initializing so it's available for security checks
- process.env.DATA_DIR = app.getPath('userData');
+ // Use the project's shared data directory in development, userData in production
+ const mainProcessDataDir = app.isPackaged
+ ? app.getPath('userData')
+ : path.join(process.cwd(), 'data');
+ process.env.DATA_DIR = mainProcessDataDir;
+ logger.info('[MAIN_PROCESS_DATA_DIR]', mainProcessDataDir);
// ALLOWED_ROOT_DIRECTORY should already be in process.env if set by user
// (it will be passed to server process, but we also need it in main process for dialog validation)
initAllowedPaths();
diff --git a/apps/ui/src/routes/terminal.tsx b/apps/ui/src/routes/terminal.tsx
index bbd0abab..c37fe263 100644
--- a/apps/ui/src/routes/terminal.tsx
+++ b/apps/ui/src/routes/terminal.tsx
@@ -1,6 +1,20 @@
import { createFileRoute } from '@tanstack/react-router';
import { TerminalView } from '@/components/views/terminal-view';
+import { z } from 'zod';
+
+const terminalSearchSchema = z.object({
+ cwd: z.string().optional(),
+ branch: z.string().optional(),
+ mode: z.enum(['tab', 'split']).optional(),
+ nonce: z.coerce.number().optional(),
+});
export const Route = createFileRoute('/terminal')({
- component: TerminalView,
+ validateSearch: terminalSearchSchema,
+ component: RouteComponent,
});
+
+function RouteComponent() {
+ const { cwd, branch, mode, nonce } = Route.useSearch();
+ return ;
+}
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts
index 5f115155..fdc90256 100644
--- a/apps/ui/src/store/app-store.ts
+++ b/apps/ui/src/store/app-store.ts
@@ -501,7 +501,7 @@ export interface ProjectAnalysis {
// Terminal panel layout types (recursive for splits)
export type TerminalPanelContent =
- | { type: 'terminal'; sessionId: string; size?: number; fontSize?: number }
+ | { type: 'terminal'; sessionId: string; size?: number; fontSize?: number; branchName?: string }
| {
type: 'split';
id: string; // Stable ID for React key stability
@@ -532,12 +532,13 @@ export interface TerminalState {
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
+ openTerminalMode: 'newTab' | 'split'; // How to open terminals from "Open in Terminal" action
}
// Persisted terminal layout - now includes sessionIds for reconnection
// Used to restore terminal layout structure when switching projects
export type PersistedTerminalPanel =
- | { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string }
+ | { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string; branchName?: string }
| {
type: 'split';
id?: string; // Optional for backwards compatibility with older persisted layouts
@@ -575,6 +576,7 @@ export interface PersistedTerminalSettings {
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
+ openTerminalMode: 'newTab' | 'split';
}
/** State for worktree init script execution */
@@ -729,6 +731,9 @@ export interface AppState {
// Editor Configuration
defaultEditorCommand: string | null; // Default editor for "Open In" action
+ // Terminal Configuration
+ defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
+
// Skills Configuration
enableSkills: boolean; // Enable Skills functionality (loads from .claude/skills/ directories)
skillsSources: Array<'user' | 'project'>; // Which directories to load Skills from
@@ -1171,6 +1176,9 @@ export interface AppActions {
// Editor Configuration actions
setDefaultEditorCommand: (command: string | null) => void;
+ // Terminal Configuration actions
+ setDefaultTerminalId: (terminalId: string | null) => void;
+
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise;
@@ -1227,7 +1235,8 @@ export interface AppActions {
addTerminalToLayout: (
sessionId: string,
direction?: 'horizontal' | 'vertical',
- targetSessionId?: string
+ targetSessionId?: string,
+ branchName?: string
) => void;
removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void;
@@ -1241,6 +1250,7 @@ export interface AppActions {
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
+ setOpenTerminalMode: (mode: 'newTab' | 'split') => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
@@ -1250,7 +1260,8 @@ export interface AppActions {
addTerminalToTab: (
sessionId: string,
tabId: string,
- direction?: 'horizontal' | 'vertical'
+ direction?: 'horizontal' | 'vertical',
+ branchName?: string
) => void;
setTerminalTabLayout: (
tabId: string,
@@ -1405,12 +1416,12 @@ const initialState: AppState = {
muteDoneSound: false, // Default to sound enabled (not muted)
serverLogLevel: 'info', // Default to info level for server logs
enableRequestLogging: true, // Default to enabled for HTTP request logging
- enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
- validationModel: 'opus', // Default to opus for GitHub issue validation
+ enhancementModel: 'claude-sonnet', // Default to sonnet for feature enhancement
+ validationModel: 'claude-opus', // Default to opus for GitHub issue validation
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
favoriteModels: [],
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
- cursorDefaultModel: 'auto', // Default to auto selection
+ cursorDefaultModel: 'cursor-auto', // Default to auto selection
enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default
codexDefaultModel: 'codex-gpt-5.2-codex', // Default to GPT-5.2-Codex
codexAutoLoadAgents: false, // Default to disabled (user must opt-in)
@@ -1432,6 +1443,7 @@ const initialState: AppState = {
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default
defaultEditorCommand: null, // Auto-detect: Cursor > VS Code > first available
+ defaultTerminalId: null, // Integrated terminal by default
enableSkills: true, // Skills enabled by default
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
enableSubagents: true, // Subagents enabled by default
@@ -1459,6 +1471,7 @@ const initialState: AppState = {
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
+ openTerminalMode: 'newTab',
},
terminalLayoutByProject: {},
specCreatingForProject: null,
@@ -1518,7 +1531,16 @@ export const useAppStore = create()((set, get) => ({
moveProjectToTrash: (projectId) => {
const project = get().projects.find((p) => p.id === projectId);
- if (!project) return;
+ if (!project) {
+ console.warn('[MOVE_TO_TRASH] Project not found:', projectId);
+ return;
+ }
+
+ console.log('[MOVE_TO_TRASH] Moving project to trash:', {
+ projectId,
+ projectName: project.name,
+ currentProjectCount: get().projects.length,
+ });
const remainingProjects = get().projects.filter((p) => p.id !== projectId);
const existingTrash = get().trashedProjects.filter((p) => p.id !== projectId);
@@ -1531,6 +1553,11 @@ export const useAppStore = create()((set, get) => ({
const isCurrent = get().currentProject?.id === projectId;
const nextCurrentProject = isCurrent ? null : get().currentProject;
+ console.log('[MOVE_TO_TRASH] Updating store with new state:', {
+ newProjectCount: remainingProjects.length,
+ newTrashedCount: [trashedProject, ...existingTrash].length,
+ });
+
set({
projects: remainingProjects,
trashedProjects: [trashedProject, ...existingTrash],
@@ -1627,16 +1654,18 @@ export const useAppStore = create()((set, get) => ({
const updatedProjects = projects.map((p) => (p.id === existingProject.id ? project : p));
set({ projects: updatedProjects });
} else {
- // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
- // Then fall back to provided theme, then current project theme, then global theme
+ // Create new project - only set theme if explicitly provided or recovering from trash
+ // Otherwise leave undefined so project uses global theme ("Use Global Theme" checked)
const trashedProject = trashedProjects.find((p) => p.path === path);
- const effectiveTheme = theme || trashedProject?.theme || currentProject?.theme || globalTheme;
+ const projectTheme =
+ theme !== undefined ? theme : (trashedProject?.theme as ThemeMode | undefined);
+
project = {
id: `project-${Date.now()}`,
name,
path,
lastOpened: new Date().toISOString(),
- theme: effectiveTheme,
+ theme: projectTheme, // May be undefined - intentional!
};
// Add the new project to the store
set({
@@ -2431,6 +2460,8 @@ export const useAppStore = create()((set, get) => ({
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
+ // Terminal Configuration actions
+ setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
@@ -2715,12 +2746,13 @@ export const useAppStore = create()((set, get) => ({
});
},
- addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId) => {
+ addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId, branchName) => {
const current = get().terminalState;
const newTerminal: TerminalPanelContent = {
type: 'terminal',
sessionId,
size: 50,
+ branchName,
};
// If no tabs, create first tab
@@ -2733,7 +2765,7 @@ export const useAppStore = create()((set, get) => ({
{
id: newTabId,
name: 'Terminal 1',
- layout: { type: 'terminal', sessionId, size: 100 },
+ layout: { type: 'terminal', sessionId, size: 100, branchName },
},
],
activeTabId: newTabId,
@@ -2808,7 +2840,7 @@ export const useAppStore = create()((set, get) => ({
let newLayout: TerminalPanelContent;
if (!activeTab.layout) {
- newLayout = { type: 'terminal', sessionId, size: 100 };
+ newLayout = { type: 'terminal', sessionId, size: 100, branchName };
} else if (targetSessionId) {
newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction);
} else {
@@ -2938,6 +2970,8 @@ export const useAppStore = create()((set, get) => ({
maxSessions: current.maxSessions,
// Preserve lastActiveProjectPath - it will be updated separately when needed
lastActiveProjectPath: current.lastActiveProjectPath,
+ // Preserve openTerminalMode - user preference
+ openTerminalMode: current.openTerminalMode,
},
});
},
@@ -3029,6 +3063,13 @@ export const useAppStore = create()((set, get) => ({
});
},
+ setOpenTerminalMode: (mode) => {
+ const current = get().terminalState;
+ set({
+ terminalState: { ...current, openTerminalMode: mode },
+ });
+ },
+
addTerminalTab: (name) => {
const current = get().terminalState;
const newTabId = `tab-${Date.now()}`;
@@ -3271,7 +3312,7 @@ export const useAppStore = create()((set, get) => ({
});
},
- addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => {
+ addTerminalToTab: (sessionId, tabId, direction = 'horizontal', branchName) => {
const current = get().terminalState;
const tab = current.tabs.find((t) => t.id === tabId);
if (!tab) return;
@@ -3280,11 +3321,12 @@ export const useAppStore = create()((set, get) => ({
type: 'terminal',
sessionId,
size: 50,
+ branchName,
};
let newLayout: TerminalPanelContent;
if (!tab.layout) {
- newLayout = { type: 'terminal', sessionId, size: 100 };
+ newLayout = { type: 'terminal', sessionId, size: 100, branchName };
} else if (tab.layout.type === 'terminal') {
newLayout = {
type: 'split',
@@ -3416,6 +3458,7 @@ export const useAppStore = create()((set, get) => ({
size: panel.size,
fontSize: panel.fontSize,
sessionId: panel.sessionId, // Preserve for reconnection
+ branchName: panel.branchName, // Preserve branch name for display
};
}
return {
diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts
index 49c1c4ad..a8e7c347 100644
--- a/apps/ui/src/types/electron.d.ts
+++ b/apps/ui/src/types/electron.d.ts
@@ -946,6 +946,58 @@ export interface WorktreeAPI {
};
error?: string;
}>;
+
+ // Get available external terminals
+ getAvailableTerminals: () => Promise<{
+ success: boolean;
+ result?: {
+ terminals: Array<{
+ id: string;
+ name: string;
+ command: string;
+ }>;
+ };
+ error?: string;
+ }>;
+
+ // Get default external terminal
+ getDefaultTerminal: () => Promise<{
+ success: boolean;
+ result?: {
+ terminalId: string;
+ terminalName: string;
+ terminalCommand: string;
+ } | null;
+ error?: string;
+ }>;
+
+ // Refresh terminal cache and re-detect available terminals
+ refreshTerminals: () => Promise<{
+ success: boolean;
+ result?: {
+ terminals: Array<{
+ id: string;
+ name: string;
+ command: string;
+ }>;
+ message: string;
+ };
+ error?: string;
+ }>;
+
+ // Open worktree in an external terminal
+ openInExternalTerminal: (
+ worktreePath: string,
+ terminalId?: string
+ ) => Promise<{
+ success: boolean;
+ result?: {
+ message: string;
+ terminalName: string;
+ };
+ error?: string;
+ }>;
+
// Initialize git repository in a project
initGit: (projectPath: string) => Promise<{
success: boolean;
diff --git a/apps/ui/tests/features/feature-manual-review-flow.spec.ts b/apps/ui/tests/features/feature-manual-review-flow.spec.ts
index 42ee7c31..ab8b077d 100644
--- a/apps/ui/tests/features/feature-manual-review-flow.spec.ts
+++ b/apps/ui/tests/features/feature-manual-review-flow.spec.ts
@@ -21,6 +21,7 @@ import {
getKanbanColumn,
authenticateForTests,
handleLoginScreenIfPresent,
+ sanitizeForTestId,
} from '../utils';
const TEST_TEMP_DIR = createTempDirPath('manual-review-test');
@@ -131,7 +132,9 @@ test.describe('Feature Manual Review Flow', () => {
}
// Verify we're on the correct project (project switcher button shows project name)
- await expect(page.getByTestId(`project-switcher-project-${projectName}`)).toBeVisible({
+ // Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
+ const sanitizedProjectName = sanitizeForTestId(projectName);
+ await expect(page.locator(`[data-testid$="-${sanitizedProjectName}"]`)).toBeVisible({
timeout: 10000,
});
diff --git a/apps/ui/tests/projects/new-project-creation.spec.ts b/apps/ui/tests/projects/new-project-creation.spec.ts
index 9d2f3362..4599e8fe 100644
--- a/apps/ui/tests/projects/new-project-creation.spec.ts
+++ b/apps/ui/tests/projects/new-project-creation.spec.ts
@@ -14,6 +14,7 @@ import {
authenticateForTests,
handleLoginScreenIfPresent,
waitForNetworkIdle,
+ sanitizeForTestId,
} from '../utils';
const TEST_TEMP_DIR = createTempDirPath('project-creation-test');
@@ -78,7 +79,9 @@ test.describe('Project Creation', () => {
// Wait for project to be set as current and visible on the page
// The project name appears in the project switcher button
- await expect(page.getByTestId(`project-switcher-project-${projectName}`)).toBeVisible({
+ // Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
+ const sanitizedProjectName = sanitizeForTestId(projectName);
+ await expect(page.locator(`[data-testid$="-${sanitizedProjectName}"]`)).toBeVisible({
timeout: 15000,
});
diff --git a/apps/ui/tests/projects/open-existing-project.spec.ts b/apps/ui/tests/projects/open-existing-project.spec.ts
index 3f4a8a36..0e3cb789 100644
--- a/apps/ui/tests/projects/open-existing-project.spec.ts
+++ b/apps/ui/tests/projects/open-existing-project.spec.ts
@@ -18,6 +18,7 @@ import {
authenticateForTests,
handleLoginScreenIfPresent,
waitForNetworkIdle,
+ sanitizeForTestId,
} from '../utils';
// Create unique temp dir for this test run
@@ -83,8 +84,24 @@ test.describe('Open Project', () => {
// Intercept settings API BEFORE any navigation to prevent restoring a currentProject
// AND inject our test project into the projects list
await page.route('**/api/settings/global', async (route) => {
- const response = await route.fetch();
- const json = await response.json();
+ let response;
+ try {
+ response = await route.fetch();
+ } catch {
+ // If fetch fails, continue with original request
+ await route.continue();
+ return;
+ }
+
+ let json;
+ try {
+ json = await response.json();
+ } catch {
+ // If response is disposed, continue with original request
+ await route.continue();
+ return;
+ }
+
if (json.settings) {
// Remove currentProjectId to prevent restoring a project
json.settings.currentProjectId = null;
@@ -104,11 +121,7 @@ test.describe('Open Project', () => {
json.settings.projects = [testProject, ...existingProjects];
}
}
- await route.fulfill({
- status: response.status(),
- headers: response.headers(),
- json,
- });
+ await route.fulfill({ response, json });
});
// Now navigate to the app
@@ -157,8 +170,10 @@ test.describe('Open Project', () => {
// Wait for a project to be set as current and visible on the page
// The project name appears in the project switcher button
+ // Use ends-with selector since data-testid format is: project-switcher-{id}-{sanitizedName}
if (targetProjectName) {
- await expect(page.getByTestId(`project-switcher-project-${targetProjectName}`)).toBeVisible({
+ const sanitizedName = sanitizeForTestId(targetProjectName);
+ await expect(page.locator(`[data-testid$="-${sanitizedName}"]`)).toBeVisible({
timeout: 15000,
});
}
diff --git a/apps/ui/tests/utils/core/elements.ts b/apps/ui/tests/utils/core/elements.ts
index af6d8df9..b46ad31e 100644
--- a/apps/ui/tests/utils/core/elements.ts
+++ b/apps/ui/tests/utils/core/elements.ts
@@ -1,5 +1,22 @@
import { Page, Locator } from '@playwright/test';
+/**
+ * Sanitize a string for use in data-testid selectors.
+ * This mirrors the sanitizeForTestId function in apps/ui/src/lib/utils.ts
+ * to ensure tests use the same sanitization logic as the component.
+ *
+ * @param name - The string to sanitize (e.g., project name)
+ * @returns A sanitized string safe for CSS selectors
+ */
+export function sanitizeForTestId(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+}
+
/**
* Get an element by its data-testid attribute
*/
diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts
index 0d18997e..1a378d56 100644
--- a/apps/ui/vite.config.mts
+++ b/apps/ui/vite.config.mts
@@ -68,6 +68,13 @@ export default defineConfig(({ command }) => {
host: process.env.HOST || '0.0.0.0',
port: parseInt(process.env.TEST_PORT || '3007', 10),
allowedHosts: true,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:3008',
+ changeOrigin: true,
+ ws: true,
+ },
+ },
},
build: {
outDir: 'dist',
diff --git a/docker-compose.dev-server.yml b/docker-compose.dev-server.yml
index 9ff0972e..ea44fffc 100644
--- a/docker-compose.dev-server.yml
+++ b/docker-compose.dev-server.yml
@@ -59,8 +59,10 @@ services:
# This ensures native modules are built for the container's architecture
- automaker-dev-node-modules:/app/node_modules
- # Persist data across restarts
- - automaker-data:/data
+ # IMPORTANT: Mount local ./data directory (not a Docker volume)
+ # This ensures Electron and web mode share the same data directory
+ # and projects opened in either mode are visible in both
+ - ./data:/data
# Persist CLI configurations
- automaker-claude-config:/home/automaker/.claude
@@ -97,9 +99,6 @@ volumes:
name: automaker-dev-node-modules
# Named volume for container-specific node_modules
- automaker-data:
- name: automaker-data
-
automaker-claude-config:
name: automaker-claude-config
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index de4ebb11..d9cf830f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -60,8 +60,9 @@ services:
# This ensures native modules are built for the container's architecture
- automaker-dev-node-modules:/app/node_modules
- # Persist data across restarts
- - automaker-data:/data
+ # IMPORTANT: Mount local ./data directory (not a Docker volume)
+ # This ensures data is consistent across Electron and web modes
+ - ./data:/data
# Persist CLI configurations
- automaker-claude-config:/home/automaker/.claude
@@ -141,9 +142,6 @@ volumes:
name: automaker-dev-node-modules
# Named volume for container-specific node_modules
- automaker-data:
- name: automaker-data
-
automaker-claude-config:
name: automaker-claude-config
diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts
index 6c636f98..df592d9e 100644
--- a/libs/model-resolver/src/resolver.ts
+++ b/libs/model-resolver/src/resolver.ts
@@ -6,10 +6,16 @@
* - Passes through Cursor models unchanged (handled by CursorProvider)
* - Provides default models per provider
* - Handles multiple model sources with priority
+ *
+ * With canonical model IDs:
+ * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
+ * - OpenCode: opencode-big-pickle, opencode-grok-code
+ * - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
*/
import {
CLAUDE_MODEL_MAP,
+ CLAUDE_CANONICAL_MAP,
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
DEFAULT_MODELS,
@@ -17,6 +23,7 @@ import {
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
+ migrateModelId,
type PhaseModelEntry,
type ThinkingLevel,
} from '@automaker/types';
@@ -29,7 +36,11 @@ const OPENAI_O_SERIES_ALLOWED_MODELS = new Set();
/**
* Resolve a model key/alias to a full model string
*
- * @param modelKey - Model key (e.g., "opus", "cursor-composer-1", "claude-sonnet-4-20250514")
+ * Handles both canonical prefixed IDs and legacy aliases:
+ * - Canonical: cursor-auto, cursor-gpt-5.2, opencode-big-pickle, claude-sonnet
+ * - Legacy: auto, composer-1, sonnet, opus
+ *
+ * @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-1", "sonnet")
* @param defaultModel - Fallback model if modelKey is undefined
* @returns Full model string
*/
@@ -47,74 +58,65 @@ export function resolveModelString(
return defaultModel;
}
- // Cursor model with explicit prefix (e.g., "cursor-composer-1") - pass through unchanged
- // CursorProvider will strip the prefix when calling the CLI
- if (modelKey.startsWith(PROVIDER_PREFIXES.cursor)) {
- const cursorModelId = stripProviderPrefix(modelKey);
- // Verify it's a valid Cursor model
- if (cursorModelId in CURSOR_MODEL_MAP) {
- console.log(
- `[ModelResolver] Using Cursor model: ${modelKey} (valid model ID: ${cursorModelId})`
- );
- return modelKey;
- }
- // Could be a cursor-prefixed model not in our map yet - still pass through
- console.log(`[ModelResolver] Passing through cursor-prefixed model: ${modelKey}`);
- return modelKey;
+ // First, migrate legacy IDs to canonical format
+ const canonicalKey = migrateModelId(modelKey);
+ if (canonicalKey !== modelKey) {
+ console.log(`[ModelResolver] Migrated legacy ID: "${modelKey}" -> "${canonicalKey}"`);
}
- // Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max") - pass through unchanged
- if (modelKey.startsWith(PROVIDER_PREFIXES.codex)) {
- console.log(`[ModelResolver] Using Codex model: ${modelKey}`);
- return modelKey;
+ // Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-1")
+ // Pass through unchanged - provider will extract bare ID for CLI
+ if (canonicalKey.startsWith(PROVIDER_PREFIXES.cursor)) {
+ console.log(`[ModelResolver] Using Cursor model: ${canonicalKey}`);
+ return canonicalKey;
}
- // OpenCode model (static or dynamic) - pass through unchanged
- // This handles models like:
- // - opencode-* (Automaker routing prefix)
- // - opencode/* (free tier models)
- // - amazon-bedrock/* (AWS Bedrock models)
- // - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro)
- if (isOpencodeModel(modelKey)) {
- console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`);
- return modelKey;
+ // Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max")
+ if (canonicalKey.startsWith(PROVIDER_PREFIXES.codex)) {
+ console.log(`[ModelResolver] Using Codex model: ${canonicalKey}`);
+ return canonicalKey;
}
- // Full Claude model string - pass through unchanged
- if (modelKey.includes('claude-')) {
- console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
- return modelKey;
+ // OpenCode model (static with opencode- prefix or dynamic with provider/model format)
+ if (isOpencodeModel(canonicalKey)) {
+ console.log(`[ModelResolver] Using OpenCode model: ${canonicalKey}`);
+ return canonicalKey;
}
- // Look up Claude model alias
- const resolved = CLAUDE_MODEL_MAP[modelKey];
- if (resolved) {
- console.log(`[ModelResolver] Resolved Claude model alias: "${modelKey}" -> "${resolved}"`);
+ // Claude canonical ID (claude-haiku, claude-sonnet, claude-opus)
+ // Map to full model string
+ if (canonicalKey in CLAUDE_CANONICAL_MAP) {
+ const resolved = CLAUDE_CANONICAL_MAP[canonicalKey as keyof typeof CLAUDE_CANONICAL_MAP];
+ console.log(`[ModelResolver] Resolved Claude canonical ID: "${canonicalKey}" -> "${resolved}"`);
return resolved;
}
- // OpenAI/Codex models - check for codex- or gpt- prefix
- if (
- CODEX_MODEL_PREFIXES.some((prefix) => modelKey.startsWith(prefix)) ||
- (OPENAI_O_SERIES_PATTERN.test(modelKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(modelKey))
- ) {
- console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`);
- return modelKey;
+ // Full Claude model string (e.g., claude-sonnet-4-5-20250929) - pass through
+ if (canonicalKey.includes('claude-')) {
+ console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`);
+ return canonicalKey;
}
- // Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o")
- // Note: This is checked AFTER Codex check to prioritize Codex for bare gpt-* models
- if (modelKey in CURSOR_MODEL_MAP) {
- // Return with cursor- prefix so provider routing works correctly
- const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`;
- console.log(
- `[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"`
- );
- return prefixedModel;
+ // Legacy Claude model alias (sonnet, opus, haiku) - support for backward compatibility
+ const resolved = CLAUDE_MODEL_MAP[canonicalKey];
+ if (resolved) {
+ console.log(`[ModelResolver] Resolved Claude legacy alias: "${canonicalKey}" -> "${resolved}"`);
+ return resolved;
+ }
+
+ // OpenAI/Codex models - check for gpt- prefix
+ if (
+ CODEX_MODEL_PREFIXES.some((prefix) => canonicalKey.startsWith(prefix)) ||
+ (OPENAI_O_SERIES_PATTERN.test(canonicalKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(canonicalKey))
+ ) {
+ console.log(`[ModelResolver] Using OpenAI/Codex model: ${canonicalKey}`);
+ return canonicalKey;
}
// Unknown model key - use default
- console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
+ console.warn(
+ `[ModelResolver] Unknown model key "${canonicalKey}", using default: "${defaultModel}"`
+ );
return defaultModel;
}
diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts
index 04452f83..6f99346c 100644
--- a/libs/model-resolver/tests/resolver.test.ts
+++ b/libs/model-resolver/tests/resolver.test.ts
@@ -78,8 +78,9 @@ describe('model-resolver', () => {
const result = resolveModelString('sonnet');
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
+ // Legacy aliases are migrated to canonical IDs then resolved
expect(consoleLogSpy).toHaveBeenCalledWith(
- expect.stringContaining('Resolved Claude model alias: "sonnet"')
+ expect.stringContaining('Migrated legacy ID: "sonnet" -> "claude-sonnet"')
);
});
@@ -88,7 +89,7 @@ describe('model-resolver', () => {
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
expect(consoleLogSpy).toHaveBeenCalledWith(
- expect.stringContaining('Resolved Claude model alias: "opus"')
+ expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
);
});
@@ -101,8 +102,9 @@ describe('model-resolver', () => {
it('should log the resolution for aliases', () => {
resolveModelString('sonnet');
+ // Legacy aliases get migrated and resolved via canonical map
expect(consoleLogSpy).toHaveBeenCalledWith(
- expect.stringContaining('Resolved Claude model alias')
+ expect.stringContaining('Resolved Claude canonical ID')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining(CLAUDE_MODEL_MAP.sonnet)
@@ -134,8 +136,9 @@ describe('model-resolver', () => {
const result = resolveModelString('composer-1');
expect(result).toBe('cursor-composer-1');
+ // Legacy bare IDs are migrated to canonical prefixed format
expect(consoleLogSpy).toHaveBeenCalledWith(
- expect.stringContaining('Detected bare Cursor model ID')
+ expect.stringContaining('Migrated legacy ID: "composer-1" -> "cursor-composer-1"')
);
});
@@ -149,17 +152,18 @@ describe('model-resolver', () => {
const result = resolveModelString('cursor-unknown-future-model');
expect(result).toBe('cursor-unknown-future-model');
- expect(consoleLogSpy).toHaveBeenCalledWith(
- expect.stringContaining('Passing through cursor-prefixed model')
- );
+ // Unknown cursor-prefixed models pass through as Cursor models
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model'));
});
it('should handle all known Cursor model IDs', () => {
+ // CURSOR_MODEL_MAP now uses prefixed keys (e.g., 'cursor-auto')
const cursorModelIds = Object.keys(CURSOR_MODEL_MAP);
for (const modelId of cursorModelIds) {
- const result = resolveModelString(`cursor-${modelId}`);
- expect(result).toBe(`cursor-${modelId}`);
+ // modelId is already prefixed (e.g., 'cursor-auto')
+ const result = resolveModelString(modelId);
+ expect(result).toBe(modelId);
}
});
});
diff --git a/libs/platform/src/editor.ts b/libs/platform/src/editor.ts
index b6daa022..5fd2a756 100644
--- a/libs/platform/src/editor.ts
+++ b/libs/platform/src/editor.ts
@@ -19,6 +19,15 @@ const execFileAsync = promisify(execFile);
const isWindows = process.platform === 'win32';
const isMac = process.platform === 'darwin';
+/**
+ * Escape a string for safe use in shell commands
+ * Handles paths with spaces, special characters, etc.
+ */
+function escapeShellArg(arg: string): string {
+ // Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
+ return `'${arg.replace(/'/g, "'\\''")}'`;
+}
+
// Cache with TTL for editor detection
let cachedEditors: EditorInfo[] | null = null;
let cacheTimestamp: number = 0;
@@ -341,3 +350,100 @@ export async function openInFileManager(targetPath: string): Promise<{ editorNam
await execFileAsync(fileManager.command, [targetPath]);
return { editorName: fileManager.name };
}
+
+/**
+ * Open a terminal in the specified directory
+ *
+ * Handles cross-platform differences:
+ * - On macOS, uses Terminal.app via 'open -a Terminal' or AppleScript for directory
+ * - On Windows, uses Windows Terminal (wt) or falls back to cmd
+ * - On Linux, uses x-terminal-emulator or common terminal emulators
+ *
+ * @param targetPath - The directory path to open the terminal in
+ * @returns Promise that resolves with terminal info when launched, rejects on error
+ */
+export async function openInTerminal(targetPath: string): Promise<{ terminalName: string }> {
+ if (isMac) {
+ // Use AppleScript to open Terminal.app in the specified directory
+ const script = `
+ tell application "Terminal"
+ do script "cd ${escapeShellArg(targetPath)}"
+ activate
+ end tell
+ `;
+ await execFileAsync('osascript', ['-e', script]);
+ return { terminalName: 'Terminal' };
+ } else if (isWindows) {
+ // Try Windows Terminal first - check if it exists before trying to spawn
+ const hasWindowsTerminal = await commandExists('wt');
+ if (hasWindowsTerminal) {
+ return await new Promise((resolve, reject) => {
+ const child: ChildProcess = spawn('wt', ['-d', targetPath], {
+ shell: true,
+ stdio: 'ignore',
+ detached: true,
+ });
+ child.unref();
+
+ child.on('error', (err) => {
+ reject(err);
+ });
+
+ setTimeout(() => resolve({ terminalName: 'Windows Terminal' }), 100);
+ });
+ }
+ // Fall back to cmd
+ return await new Promise((resolve, reject) => {
+ const child: ChildProcess = spawn(
+ 'cmd',
+ ['/c', 'start', 'cmd', '/k', `cd /d "${targetPath}"`],
+ {
+ shell: true,
+ stdio: 'ignore',
+ detached: true,
+ }
+ );
+ child.unref();
+
+ child.on('error', (err) => {
+ reject(err);
+ });
+
+ setTimeout(() => resolve({ terminalName: 'Command Prompt' }), 100);
+ });
+ } else {
+ // Linux: Try common terminal emulators in order
+ const terminals = [
+ {
+ name: 'GNOME Terminal',
+ command: 'gnome-terminal',
+ args: ['--working-directory', targetPath],
+ },
+ { name: 'Konsole', command: 'konsole', args: ['--workdir', targetPath] },
+ {
+ name: 'xfce4-terminal',
+ command: 'xfce4-terminal',
+ args: ['--working-directory', targetPath],
+ },
+ {
+ name: 'xterm',
+ command: 'xterm',
+ args: ['-e', 'sh', '-c', `cd ${escapeShellArg(targetPath)} && $SHELL`],
+ },
+ {
+ name: 'x-terminal-emulator',
+ command: 'x-terminal-emulator',
+ args: ['--working-directory', targetPath],
+ },
+ ];
+
+ for (const terminal of terminals) {
+ if (await commandExists(terminal.command)) {
+ await execFileAsync(terminal.command, terminal.args);
+ return { terminalName: terminal.name };
+ }
+ }
+
+ throw new Error('No terminal emulator found');
+ }
+}
diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts
index d51845f9..5952ba2d 100644
--- a/libs/platform/src/index.ts
+++ b/libs/platform/src/index.ts
@@ -175,4 +175,14 @@ export {
findEditorByCommand,
openInEditor,
openInFileManager,
+ openInTerminal,
} from './editor.js';
+
+// External terminal detection and launching
+export {
+ clearTerminalCache,
+ detectAllTerminals,
+ detectDefaultTerminal,
+ findTerminalById,
+ openInExternalTerminal,
+} from './terminal.js';
diff --git a/libs/platform/src/terminal.ts b/libs/platform/src/terminal.ts
new file mode 100644
index 00000000..4bbe120a
--- /dev/null
+++ b/libs/platform/src/terminal.ts
@@ -0,0 +1,607 @@
+/**
+ * Cross-platform terminal detection and launching utilities
+ *
+ * Handles:
+ * - Detecting available external terminals on the system
+ * - Cross-platform terminal launching
+ * - Caching of detected terminals for performance
+ */
+
+import { execFile, spawn, type ChildProcess } from 'child_process';
+import { promisify } from 'util';
+import { homedir } from 'os';
+import { join } from 'path';
+import { access } from 'fs/promises';
+import type { TerminalInfo } from '@automaker/types';
+
+const execFileAsync = promisify(execFile);
+
+// Platform detection
+const isWindows = process.platform === 'win32';
+const isMac = process.platform === 'darwin';
+const isLinux = process.platform === 'linux';
+
+// Cache with TTL for terminal detection
+let cachedTerminals: TerminalInfo[] | null = null;
+let cacheTimestamp: number = 0;
+const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * Check if the terminal cache is still valid
+ */
+function isCacheValid(): boolean {
+ return cachedTerminals !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS;
+}
+
+/**
+ * Clear the terminal detection cache
+ * Useful when terminals may have been installed/uninstalled
+ */
+export function clearTerminalCache(): void {
+ cachedTerminals = null;
+ cacheTimestamp = 0;
+}
+
+/**
+ * Check if a CLI command exists in PATH
+ * Uses platform-specific command lookup (where on Windows, which on Unix)
+ */
+async function commandExists(cmd: string): Promise {
+ try {
+ const whichCmd = isWindows ? 'where' : 'which';
+ await execFileAsync(whichCmd, [cmd]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if a macOS app bundle exists and return the path if found
+ * Checks /Applications, /System/Applications (for built-in apps), and ~/Applications
+ */
+async function findMacApp(appName: string): Promise {
+ if (!isMac) return null;
+
+ // Check /Applications first (third-party apps)
+ const appPath = join('/Applications', `${appName}.app`);
+ try {
+ await access(appPath);
+ return appPath;
+ } catch {
+ // Not in /Applications
+ }
+
+ // Check /System/Applications (built-in macOS apps like Terminal on Catalina+)
+ const systemAppPath = join('/System/Applications', `${appName}.app`);
+ try {
+ await access(systemAppPath);
+ return systemAppPath;
+ } catch {
+ // Not in /System/Applications
+ }
+
+ // Check ~/Applications (used by some installers)
+ const userAppPath = join(homedir(), 'Applications', `${appName}.app`);
+ try {
+ await access(userAppPath);
+ return userAppPath;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Check if a Windows path exists
+ */
+async function windowsPathExists(path: string): Promise {
+ if (!isWindows) return false;
+
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Terminal definition with CLI command and platform-specific identifiers
+ */
+interface TerminalDefinition {
+ id: string;
+ name: string;
+ /** CLI command (cross-platform, checked via which/where) */
+ cliCommand?: string;
+ /** Alternative CLI commands to check */
+ cliAliases?: readonly string[];
+ /** macOS app bundle name */
+ macAppName?: string;
+ /** Windows executable paths to check */
+ windowsPaths?: readonly string[];
+ /** Linux binary paths to check */
+ linuxPaths?: readonly string[];
+ /** Platform restriction */
+ platform?: 'darwin' | 'win32' | 'linux';
+}
+
+/**
+ * List of supported terminals in priority order
+ */
+const SUPPORTED_TERMINALS: TerminalDefinition[] = [
+ // macOS terminals
+ {
+ id: 'iterm2',
+ name: 'iTerm2',
+ cliCommand: 'iterm2',
+ macAppName: 'iTerm',
+ platform: 'darwin',
+ },
+ {
+ id: 'warp',
+ name: 'Warp',
+ cliCommand: 'warp',
+ macAppName: 'Warp',
+ platform: 'darwin',
+ },
+ {
+ id: 'ghostty',
+ name: 'Ghostty',
+ cliCommand: 'ghostty',
+ macAppName: 'Ghostty',
+ },
+ {
+ id: 'rio',
+ name: 'Rio',
+ cliCommand: 'rio',
+ macAppName: 'Rio',
+ },
+ {
+ id: 'alacritty',
+ name: 'Alacritty',
+ cliCommand: 'alacritty',
+ macAppName: 'Alacritty',
+ },
+ {
+ id: 'wezterm',
+ name: 'WezTerm',
+ cliCommand: 'wezterm',
+ macAppName: 'WezTerm',
+ },
+ {
+ id: 'kitty',
+ name: 'Kitty',
+ cliCommand: 'kitty',
+ macAppName: 'kitty',
+ },
+ {
+ id: 'hyper',
+ name: 'Hyper',
+ cliCommand: 'hyper',
+ macAppName: 'Hyper',
+ },
+ {
+ id: 'tabby',
+ name: 'Tabby',
+ cliCommand: 'tabby',
+ macAppName: 'Tabby',
+ },
+ {
+ id: 'terminal-macos',
+ name: 'System Terminal',
+ macAppName: 'Utilities/Terminal',
+ platform: 'darwin',
+ },
+
+ // Windows terminals
+ {
+ id: 'windows-terminal',
+ name: 'Windows Terminal',
+ cliCommand: 'wt',
+ windowsPaths: [join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe')],
+ platform: 'win32',
+ },
+ {
+ id: 'powershell',
+ name: 'PowerShell',
+ cliCommand: 'pwsh',
+ cliAliases: ['powershell'],
+ windowsPaths: [
+ join(
+ process.env.SYSTEMROOT || 'C:\\Windows',
+ 'System32',
+ 'WindowsPowerShell',
+ 'v1.0',
+ 'powershell.exe'
+ ),
+ ],
+ platform: 'win32',
+ },
+ {
+ id: 'cmd',
+ name: 'Command Prompt',
+ cliCommand: 'cmd',
+ windowsPaths: [join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe')],
+ platform: 'win32',
+ },
+ {
+ id: 'git-bash',
+ name: 'Git Bash',
+ windowsPaths: [
+ join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'git-bash.exe'),
+ join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Git', 'git-bash.exe'),
+ ],
+ platform: 'win32',
+ },
+
+ // Linux terminals
+ {
+ id: 'gnome-terminal',
+ name: 'GNOME Terminal',
+ cliCommand: 'gnome-terminal',
+ platform: 'linux',
+ },
+ {
+ id: 'konsole',
+ name: 'Konsole',
+ cliCommand: 'konsole',
+ platform: 'linux',
+ },
+ {
+ id: 'xfce4-terminal',
+ name: 'XFCE4 Terminal',
+ cliCommand: 'xfce4-terminal',
+ platform: 'linux',
+ },
+ {
+ id: 'tilix',
+ name: 'Tilix',
+ cliCommand: 'tilix',
+ platform: 'linux',
+ },
+ {
+ id: 'terminator',
+ name: 'Terminator',
+ cliCommand: 'terminator',
+ platform: 'linux',
+ },
+ {
+ id: 'foot',
+ name: 'Foot',
+ cliCommand: 'foot',
+ platform: 'linux',
+ },
+ {
+ id: 'xterm',
+ name: 'XTerm',
+ cliCommand: 'xterm',
+ platform: 'linux',
+ },
+];
+
+/**
+ * Try to find a terminal - checks CLI, macOS app bundle, or Windows paths
+ * Returns TerminalInfo if found, null otherwise
+ */
+async function findTerminal(definition: TerminalDefinition): Promise {
+ // Skip if terminal is for a different platform
+ if (definition.platform) {
+ if (definition.platform === 'darwin' && !isMac) return null;
+ if (definition.platform === 'win32' && !isWindows) return null;
+ if (definition.platform === 'linux' && !isLinux) return null;
+ }
+
+ // Try CLI command first (works on all platforms)
+ const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])].filter(
+ Boolean
+ ) as string[];
+ for (const cliCommand of cliCandidates) {
+ if (await commandExists(cliCommand)) {
+ return {
+ id: definition.id,
+ name: definition.name,
+ command: cliCommand,
+ };
+ }
+ }
+
+ // Try macOS app bundle
+ if (isMac && definition.macAppName) {
+ const appPath = await findMacApp(definition.macAppName);
+ if (appPath) {
+ return {
+ id: definition.id,
+ name: definition.name,
+ command: `open -a "${appPath}"`,
+ };
+ }
+ }
+
+ // Try Windows paths
+ if (isWindows && definition.windowsPaths) {
+ for (const windowsPath of definition.windowsPaths) {
+ if (await windowsPathExists(windowsPath)) {
+ return {
+ id: definition.id,
+ name: definition.name,
+ command: windowsPath,
+ };
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Detect all available external terminals on the system
+ * Results are cached for 5 minutes for performance
+ */
+export async function detectAllTerminals(): Promise {
+ // Return cached result if still valid
+ if (isCacheValid() && cachedTerminals) {
+ return cachedTerminals;
+ }
+
+ // Check all terminals in parallel for better performance
+ const terminalChecks = SUPPORTED_TERMINALS.map((def) => findTerminal(def));
+ const results = await Promise.all(terminalChecks);
+
+ // Filter out null results (terminals not found)
+ const terminals = results.filter((t): t is TerminalInfo => t !== null);
+
+ // Update cache
+ cachedTerminals = terminals;
+ cacheTimestamp = Date.now();
+
+ return terminals;
+}
+
+/**
+ * Detect the default (first available) external terminal on the system
+ * Returns the highest priority terminal that is installed, or null if none found
+ */
+export async function detectDefaultTerminal(): Promise {
+ const terminals = await detectAllTerminals();
+ return terminals[0] ?? null;
+}
+
+/**
+ * Find a specific terminal by ID
+ * Returns the terminal info if available, null otherwise
+ */
+export async function findTerminalById(id: string): Promise {
+ const terminals = await detectAllTerminals();
+ return terminals.find((t) => t.id === id) ?? null;
+}
+
+/**
+ * Open a directory in the specified external terminal
+ *
+ * Handles cross-platform differences:
+ * - On macOS, uses 'open -a' for app bundles or direct command with --directory flag
+ * - On Windows, uses spawn with shell:true
+ * - On Linux, uses direct execution with working directory
+ *
+ * @param targetPath - The directory path to open
+ * @param terminalId - The terminal ID to use (optional, uses default if not specified)
+ * @returns Promise that resolves with terminal info when launched, rejects on error
+ */
+export async function openInExternalTerminal(
+ targetPath: string,
+ terminalId?: string
+): Promise<{ terminalName: string }> {
+ // Determine which terminal to use
+ let terminal: TerminalInfo | null;
+
+ if (terminalId) {
+ terminal = await findTerminalById(terminalId);
+ if (!terminal) {
+ // Fall back to default if specified terminal not found
+ terminal = await detectDefaultTerminal();
+ }
+ } else {
+ terminal = await detectDefaultTerminal();
+ }
+
+ if (!terminal) {
+ throw new Error('No external terminal available');
+ }
+
+ // Execute the terminal
+ await executeTerminalCommand(terminal, targetPath);
+
+ return { terminalName: terminal.name };
+}
+
+/**
+ * Execute a terminal command to open at a specific path
+ * Handles platform-specific differences in command execution
+ */
+async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string): Promise {
+ const { id, command } = terminal;
+
+ // Handle 'open -a "AppPath"' style commands (macOS app bundles)
+ if (command.startsWith('open -a ')) {
+ const appPath = command.replace('open -a ', '').replace(/"/g, '');
+
+ // Different terminals have different ways to open at a directory
+ if (id === 'iterm2') {
+ // iTerm2: Use AppleScript to open a new window at the path
+ await execFileAsync('osascript', [
+ '-e',
+ `tell application "iTerm"
+ create window with default profile
+ tell current session of current window
+ write text "cd ${escapeShellArg(targetPath)}"
+ end tell
+ end tell`,
+ ]);
+ } else if (id === 'terminal-macos') {
+ // macOS Terminal: Use AppleScript
+ await execFileAsync('osascript', [
+ '-e',
+ `tell application "Terminal"
+ do script "cd ${escapeShellArg(targetPath)}"
+ activate
+ end tell`,
+ ]);
+ } else if (id === 'warp') {
+ // Warp: Open app and use AppleScript to cd
+ await execFileAsync('open', ['-a', appPath, targetPath]);
+ } else {
+ // Generic: Just open the app with the directory as argument
+ await execFileAsync('open', ['-a', appPath, targetPath]);
+ }
+ return;
+ }
+
+ // Handle different terminals based on their ID
+ switch (id) {
+ case 'iterm2':
+ // iTerm2 CLI mode
+ await execFileAsync('osascript', [
+ '-e',
+ `tell application "iTerm"
+ create window with default profile
+ tell current session of current window
+ write text "cd ${escapeShellArg(targetPath)}"
+ end tell
+ end tell`,
+ ]);
+ break;
+
+ case 'ghostty':
+ // Ghostty: uses --working-directory=PATH format (single arg)
+ await spawnDetached(command, [`--working-directory=${targetPath}`]);
+ break;
+
+ case 'alacritty':
+ // Alacritty: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'wezterm':
+ // WezTerm: uses start --cwd flag
+ await spawnDetached(command, ['start', '--cwd', targetPath]);
+ break;
+
+ case 'kitty':
+ // Kitty: uses --directory flag
+ await spawnDetached(command, ['--directory', targetPath]);
+ break;
+
+ case 'hyper':
+ // Hyper: open at directory by setting cwd
+ await spawnDetached(command, [targetPath]);
+ break;
+
+ case 'tabby':
+ // Tabby: open at directory
+ await spawnDetached(command, ['open', targetPath]);
+ break;
+
+ case 'rio':
+ // Rio: uses --working-dir flag
+ await spawnDetached(command, ['--working-dir', targetPath]);
+ break;
+
+ case 'windows-terminal':
+ // Windows Terminal: uses -d flag for directory
+ await spawnDetached(command, ['-d', targetPath], { shell: true });
+ break;
+
+ case 'powershell':
+ case 'cmd':
+ // PowerShell/CMD: Start in directory with /K to keep open
+ await spawnDetached('start', [command, '/K', `cd /d "${targetPath}"`], {
+ shell: true,
+ });
+ break;
+
+ case 'git-bash':
+ // Git Bash: uses --cd flag
+ await spawnDetached(command, ['--cd', targetPath], { shell: true });
+ break;
+
+ case 'gnome-terminal':
+ // GNOME Terminal: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'konsole':
+ // Konsole: uses --workdir flag
+ await spawnDetached(command, ['--workdir', targetPath]);
+ break;
+
+ case 'xfce4-terminal':
+ // XFCE4 Terminal: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'tilix':
+ // Tilix: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'terminator':
+ // Terminator: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'foot':
+ // Foot: uses --working-directory flag
+ await spawnDetached(command, ['--working-directory', targetPath]);
+ break;
+
+ case 'xterm':
+ // XTerm: uses -e to run a shell in the directory
+ await spawnDetached(command, [
+ '-e',
+ 'sh',
+ '-c',
+ `cd ${escapeShellArg(targetPath)} && $SHELL`,
+ ]);
+ break;
+
+ default:
+ // Generic fallback: try to run the command with the directory as argument
+ await spawnDetached(command, [targetPath]);
+ }
+}
+
+/**
+ * Spawn a detached process that won't block the parent
+ */
+function spawnDetached(
+ command: string,
+ args: string[],
+ options: { shell?: boolean } = {}
+): Promise {
+ return new Promise((resolve, reject) => {
+ const child: ChildProcess = spawn(command, args, {
+ shell: options.shell ?? false,
+ stdio: 'ignore',
+ detached: true,
+ });
+
+ // Unref to allow the parent process to exit independently
+ child.unref();
+
+ child.on('error', (err) => {
+ reject(err);
+ });
+
+ // Resolve after a small delay to catch immediate spawn errors
+ // Terminals run in background, so we don't wait for them to exit
+ setTimeout(() => resolve(), 100);
+ });
+}
+
+/**
+ * Escape a string for safe use in shell commands
+ */
+function escapeShellArg(arg: string): string {
+ // Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string
+ return `'${arg.replace(/'/g, "'\\''")}'`;
+}
diff --git a/libs/spec-parser/package.json b/libs/spec-parser/package.json
new file mode 100644
index 00000000..4d003f7f
--- /dev/null
+++ b/libs/spec-parser/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@automaker/spec-parser",
+ "version": "1.0.0",
+ "type": "module",
+ "description": "XML spec parser for AutoMaker - parses and generates app_spec.txt XML",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc",
+ "watch": "tsc --watch",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "keywords": [
+ "automaker",
+ "spec-parser",
+ "xml"
+ ],
+ "author": "AutoMaker Team",
+ "license": "SEE LICENSE IN LICENSE",
+ "engines": {
+ "node": ">=22.0.0 <23.0.0"
+ },
+ "dependencies": {
+ "@automaker/types": "1.0.0",
+ "fast-xml-parser": "^5.3.3"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.3",
+ "typescript": "5.9.3",
+ "vitest": "4.0.16"
+ }
+}
diff --git a/libs/spec-parser/src/index.ts b/libs/spec-parser/src/index.ts
new file mode 100644
index 00000000..37fb9221
--- /dev/null
+++ b/libs/spec-parser/src/index.ts
@@ -0,0 +1,26 @@
+/**
+ * @automaker/spec-parser
+ *
+ * XML spec parser for AutoMaker - parses and generates app_spec.txt XML.
+ * This package provides utilities for:
+ * - Parsing XML spec content into SpecOutput objects
+ * - Converting SpecOutput objects back to XML
+ * - Validating spec data
+ */
+
+// Re-export types from @automaker/types for convenience
+export type { SpecOutput } from '@automaker/types';
+
+// XML utilities
+export { escapeXml, unescapeXml, extractXmlSection, extractXmlElements } from './xml-utils.js';
+
+// XML to Spec parsing
+export { xmlToSpec } from './xml-to-spec.js';
+export type { ParseResult } from './xml-to-spec.js';
+
+// Spec to XML conversion
+export { specToXml } from './spec-to-xml.js';
+
+// Validation
+export { validateSpec, isValidSpecXml } from './validate.js';
+export type { ValidationResult } from './validate.js';
diff --git a/libs/spec-parser/src/spec-to-xml.ts b/libs/spec-parser/src/spec-to-xml.ts
new file mode 100644
index 00000000..c79a7a38
--- /dev/null
+++ b/libs/spec-parser/src/spec-to-xml.ts
@@ -0,0 +1,88 @@
+/**
+ * SpecOutput to XML converter.
+ * Converts a structured SpecOutput object back to XML format.
+ */
+
+import type { SpecOutput } from '@automaker/types';
+import { escapeXml } from './xml-utils.js';
+
+/**
+ * Convert structured spec output to XML format.
+ *
+ * @param spec - The SpecOutput object to convert
+ * @returns XML string formatted for app_spec.txt
+ */
+export function specToXml(spec: SpecOutput): string {
+ const indent = ' ';
+
+ let xml = `
+
+${indent}${escapeXml(spec.project_name)}
+
+${indent}
+${indent}${indent}${escapeXml(spec.overview)}
+${indent}
+
+${indent}
+${spec.technology_stack.map((t) => `${indent}${indent}${escapeXml(t)} `).join('\n')}
+${indent}
+
+${indent}
+${spec.core_capabilities.map((c) => `${indent}${indent}${escapeXml(c)} `).join('\n')}
+${indent}
+
+${indent}
+${spec.implemented_features
+ .map(
+ (f) => `${indent}${indent}
+${indent}${indent}${indent}${escapeXml(f.name)}
+${indent}${indent}${indent}${escapeXml(f.description)} ${
+ f.file_locations && f.file_locations.length > 0
+ ? `\n${indent}${indent}${indent}
+${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)} `).join('\n')}
+${indent}${indent}${indent} `
+ : ''
+ }
+${indent}${indent} `
+ )
+ .join('\n')}
+${indent} `;
+
+ // Optional sections
+ if (spec.additional_requirements && spec.additional_requirements.length > 0) {
+ xml += `
+
+${indent}
+${spec.additional_requirements.map((r) => `${indent}${indent}${escapeXml(r)} `).join('\n')}
+${indent} `;
+ }
+
+ if (spec.development_guidelines && spec.development_guidelines.length > 0) {
+ xml += `
+
+${indent}
+${spec.development_guidelines.map((g) => `${indent}${indent}${escapeXml(g)} `).join('\n')}
+${indent} `;
+ }
+
+ if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
+ xml += `
+
+${indent}
+${spec.implementation_roadmap
+ .map(
+ (r) => `${indent}${indent}
+${indent}${indent}${indent}${escapeXml(r.phase)}
+${indent}${indent}${indent}${escapeXml(r.status)}
+${indent}${indent}${indent}${escapeXml(r.description)}
+${indent}${indent} `
+ )
+ .join('\n')}
+${indent} `;
+ }
+
+ xml += `
+ `;
+
+ return xml;
+}
diff --git a/libs/spec-parser/src/validate.ts b/libs/spec-parser/src/validate.ts
new file mode 100644
index 00000000..0d74dcd7
--- /dev/null
+++ b/libs/spec-parser/src/validate.ts
@@ -0,0 +1,143 @@
+/**
+ * Validation utilities for SpecOutput objects.
+ */
+
+import type { SpecOutput } from '@automaker/types';
+
+/**
+ * Validation result containing errors if any.
+ */
+export interface ValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+
+/**
+ * Validate a SpecOutput object for required fields and data integrity.
+ *
+ * @param spec - The SpecOutput object to validate
+ * @returns ValidationResult with errors if validation fails
+ */
+export function validateSpec(spec: SpecOutput | null | undefined): ValidationResult {
+ const errors: string[] = [];
+
+ if (!spec) {
+ return { valid: false, errors: ['Spec is null or undefined'] };
+ }
+
+ // Required string fields
+ if (!spec.project_name || typeof spec.project_name !== 'string') {
+ errors.push('project_name is required and must be a string');
+ } else if (spec.project_name.trim().length === 0) {
+ errors.push('project_name cannot be empty');
+ }
+
+ if (!spec.overview || typeof spec.overview !== 'string') {
+ errors.push('overview is required and must be a string');
+ } else if (spec.overview.trim().length === 0) {
+ errors.push('overview cannot be empty');
+ }
+
+ // Required array fields
+ if (!Array.isArray(spec.technology_stack)) {
+ errors.push('technology_stack is required and must be an array');
+ } else if (spec.technology_stack.length === 0) {
+ errors.push('technology_stack must have at least one item');
+ } else if (spec.technology_stack.some((t) => typeof t !== 'string' || t.trim() === '')) {
+ errors.push('technology_stack items must be non-empty strings');
+ }
+
+ if (!Array.isArray(spec.core_capabilities)) {
+ errors.push('core_capabilities is required and must be an array');
+ } else if (spec.core_capabilities.length === 0) {
+ errors.push('core_capabilities must have at least one item');
+ } else if (spec.core_capabilities.some((c) => typeof c !== 'string' || c.trim() === '')) {
+ errors.push('core_capabilities items must be non-empty strings');
+ }
+
+ // Implemented features
+ if (!Array.isArray(spec.implemented_features)) {
+ errors.push('implemented_features is required and must be an array');
+ } else {
+ spec.implemented_features.forEach((f, i) => {
+ if (!f.name || typeof f.name !== 'string' || f.name.trim() === '') {
+ errors.push(`implemented_features[${i}].name is required and must be a non-empty string`);
+ }
+ if (!f.description || typeof f.description !== 'string') {
+ errors.push(`implemented_features[${i}].description is required and must be a string`);
+ }
+ if (f.file_locations !== undefined) {
+ if (!Array.isArray(f.file_locations)) {
+ errors.push(`implemented_features[${i}].file_locations must be an array if provided`);
+ } else if (f.file_locations.some((loc) => typeof loc !== 'string' || loc.trim() === '')) {
+ errors.push(`implemented_features[${i}].file_locations items must be non-empty strings`);
+ }
+ }
+ });
+ }
+
+ // Optional array fields
+ if (spec.additional_requirements !== undefined) {
+ if (!Array.isArray(spec.additional_requirements)) {
+ errors.push('additional_requirements must be an array if provided');
+ } else if (spec.additional_requirements.some((r) => typeof r !== 'string' || r.trim() === '')) {
+ errors.push('additional_requirements items must be non-empty strings');
+ }
+ }
+
+ if (spec.development_guidelines !== undefined) {
+ if (!Array.isArray(spec.development_guidelines)) {
+ errors.push('development_guidelines must be an array if provided');
+ } else if (spec.development_guidelines.some((g) => typeof g !== 'string' || g.trim() === '')) {
+ errors.push('development_guidelines items must be non-empty strings');
+ }
+ }
+
+ // Implementation roadmap
+ if (spec.implementation_roadmap !== undefined) {
+ if (!Array.isArray(spec.implementation_roadmap)) {
+ errors.push('implementation_roadmap must be an array if provided');
+ } else {
+ const validStatuses = ['completed', 'in_progress', 'pending'];
+ spec.implementation_roadmap.forEach((r, i) => {
+ if (!r.phase || typeof r.phase !== 'string' || r.phase.trim() === '') {
+ errors.push(
+ `implementation_roadmap[${i}].phase is required and must be a non-empty string`
+ );
+ }
+ if (!r.status || !validStatuses.includes(r.status)) {
+ errors.push(
+ `implementation_roadmap[${i}].status must be one of: ${validStatuses.join(', ')}`
+ );
+ }
+ if (!r.description || typeof r.description !== 'string') {
+ errors.push(`implementation_roadmap[${i}].description is required and must be a string`);
+ }
+ });
+ }
+ }
+
+ return { valid: errors.length === 0, errors };
+}
+
+/**
+ * Check if XML content appears to be a valid spec XML (basic structure check).
+ * This is a quick check, not a full validation.
+ *
+ * @param xmlContent - The XML content to check
+ * @returns true if the content appears to be valid spec XML
+ */
+export function isValidSpecXml(xmlContent: string): boolean {
+ if (!xmlContent || typeof xmlContent !== 'string') {
+ return false;
+ }
+
+ // Check for essential elements
+ const hasRoot = xmlContent.includes('');
+ const hasProjectName = /[\s\S]*?<\/project_name>/.test(xmlContent);
+ const hasOverview = /[\s\S]*?<\/overview>/.test(xmlContent);
+ const hasTechStack = /[\s\S]*?<\/technology_stack>/.test(xmlContent);
+ const hasCapabilities = /[\s\S]*?<\/core_capabilities>/.test(xmlContent);
+
+ return hasRoot && hasProjectName && hasOverview && hasTechStack && hasCapabilities;
+}
diff --git a/libs/spec-parser/src/xml-to-spec.ts b/libs/spec-parser/src/xml-to-spec.ts
new file mode 100644
index 00000000..fb437f2e
--- /dev/null
+++ b/libs/spec-parser/src/xml-to-spec.ts
@@ -0,0 +1,232 @@
+/**
+ * XML to SpecOutput parser.
+ * Parses app_spec.txt XML content into a structured SpecOutput object.
+ * Uses fast-xml-parser for robust XML parsing.
+ */
+
+import { XMLParser } from 'fast-xml-parser';
+import type { SpecOutput } from '@automaker/types';
+
+/**
+ * Result of parsing XML content.
+ */
+export interface ParseResult {
+ success: boolean;
+ spec: SpecOutput | null;
+ errors: string[];
+}
+
+// Configure the XML parser
+const parser = new XMLParser({
+ ignoreAttributes: true,
+ trimValues: true,
+ // Preserve arrays for elements that can have multiple values
+ isArray: (name) => {
+ return [
+ 'technology',
+ 'capability',
+ 'feature',
+ 'location',
+ 'requirement',
+ 'guideline',
+ 'phase',
+ ].includes(name);
+ },
+});
+
+/**
+ * Safely get a string value from parsed XML, handling various input types.
+ */
+function getString(value: unknown): string {
+ if (typeof value === 'string') return value.trim();
+ if (typeof value === 'number') return String(value);
+ if (value === null || value === undefined) return '';
+ return '';
+}
+
+/**
+ * Safely get an array of strings from parsed XML.
+ */
+function getStringArray(value: unknown): string[] {
+ if (!value) return [];
+ if (Array.isArray(value)) {
+ return value.map((item) => getString(item)).filter((s) => s.length > 0);
+ }
+ const str = getString(value);
+ return str ? [str] : [];
+}
+
+/**
+ * Parse implemented features from the parsed XML object.
+ */
+function parseImplementedFeatures(featuresSection: unknown): SpecOutput['implemented_features'] {
+ const features: SpecOutput['implemented_features'] = [];
+
+ if (!featuresSection || typeof featuresSection !== 'object') {
+ return features;
+ }
+
+ const section = featuresSection as Record;
+ const featureList = section.feature;
+
+ if (!featureList) return features;
+
+ const featureArray = Array.isArray(featureList) ? featureList : [featureList];
+
+ for (const feature of featureArray) {
+ if (typeof feature !== 'object' || feature === null) continue;
+
+ const f = feature as Record;
+ const name = getString(f.name);
+ const description = getString(f.description);
+
+ if (!name) continue;
+
+ const locationsSection = f.file_locations as Record | undefined;
+ const file_locations = locationsSection ? getStringArray(locationsSection.location) : undefined;
+
+ features.push({
+ name,
+ description,
+ ...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
+ });
+ }
+
+ return features;
+}
+
+/**
+ * Parse implementation roadmap phases from the parsed XML object.
+ */
+function parseImplementationRoadmap(roadmapSection: unknown): SpecOutput['implementation_roadmap'] {
+ if (!roadmapSection || typeof roadmapSection !== 'object') {
+ return undefined;
+ }
+
+ const section = roadmapSection as Record;
+ const phaseList = section.phase;
+
+ if (!phaseList) return undefined;
+
+ const phaseArray = Array.isArray(phaseList) ? phaseList : [phaseList];
+ const roadmap: NonNullable = [];
+
+ for (const phase of phaseArray) {
+ if (typeof phase !== 'object' || phase === null) continue;
+
+ const p = phase as Record;
+ const phaseName = getString(p.name);
+ const statusRaw = getString(p.status);
+ const description = getString(p.description);
+
+ if (!phaseName) continue;
+
+ const status = (
+ ['completed', 'in_progress', 'pending'].includes(statusRaw) ? statusRaw : 'pending'
+ ) as 'completed' | 'in_progress' | 'pending';
+
+ roadmap.push({ phase: phaseName, status, description });
+ }
+
+ return roadmap.length > 0 ? roadmap : undefined;
+}
+
+/**
+ * Parse XML content into a SpecOutput object.
+ *
+ * @param xmlContent - The raw XML content from app_spec.txt
+ * @returns ParseResult with the parsed spec or errors
+ */
+export function xmlToSpec(xmlContent: string): ParseResult {
+ const errors: string[] = [];
+
+ // Check for root element before parsing
+ if (!xmlContent.includes('')) {
+ return {
+ success: false,
+ spec: null,
+ errors: ['Missing root element'],
+ };
+ }
+
+ // Parse the XML
+ let parsed: Record;
+ try {
+ parsed = parser.parse(xmlContent) as Record;
+ } catch (e) {
+ return {
+ success: false,
+ spec: null,
+ errors: [`XML parsing error: ${e instanceof Error ? e.message : 'Unknown error'}`],
+ };
+ }
+
+ const root = parsed.project_specification as Record | undefined;
+
+ if (!root) {
+ return {
+ success: false,
+ spec: null,
+ errors: ['Missing root element'],
+ };
+ }
+
+ // Extract required fields
+ const project_name = getString(root.project_name);
+ if (!project_name) {
+ errors.push('Missing or empty ');
+ }
+
+ const overview = getString(root.overview);
+ if (!overview) {
+ errors.push('Missing or empty ');
+ }
+
+ // Extract technology stack
+ const techSection = root.technology_stack as Record | undefined;
+ const technology_stack = techSection ? getStringArray(techSection.technology) : [];
+ if (technology_stack.length === 0) {
+ errors.push('Missing or empty ');
+ }
+
+ // Extract core capabilities
+ const capSection = root.core_capabilities as Record | undefined;
+ const core_capabilities = capSection ? getStringArray(capSection.capability) : [];
+ if (core_capabilities.length === 0) {
+ errors.push('Missing or empty ');
+ }
+
+ // Extract implemented features
+ const implemented_features = parseImplementedFeatures(root.implemented_features);
+
+ // Extract optional sections
+ const reqSection = root.additional_requirements as Record | undefined;
+ const additional_requirements = reqSection ? getStringArray(reqSection.requirement) : undefined;
+
+ const guideSection = root.development_guidelines as Record | undefined;
+ const development_guidelines = guideSection ? getStringArray(guideSection.guideline) : undefined;
+
+ const implementation_roadmap = parseImplementationRoadmap(root.implementation_roadmap);
+
+ // Build spec object
+ const spec: SpecOutput = {
+ project_name,
+ overview,
+ technology_stack,
+ core_capabilities,
+ implemented_features,
+ ...(additional_requirements && additional_requirements.length > 0
+ ? { additional_requirements }
+ : {}),
+ ...(development_guidelines && development_guidelines.length > 0
+ ? { development_guidelines }
+ : {}),
+ ...(implementation_roadmap ? { implementation_roadmap } : {}),
+ };
+
+ return {
+ success: errors.length === 0,
+ spec,
+ errors,
+ };
+}
diff --git a/libs/spec-parser/src/xml-utils.ts b/libs/spec-parser/src/xml-utils.ts
new file mode 100644
index 00000000..acbb688c
--- /dev/null
+++ b/libs/spec-parser/src/xml-utils.ts
@@ -0,0 +1,79 @@
+/**
+ * XML utility functions for escaping, unescaping, and extracting XML content.
+ * These are pure functions with no dependencies for maximum reusability.
+ */
+
+/**
+ * Escape special XML characters.
+ * Handles undefined/null values by converting them to empty strings.
+ */
+export function escapeXml(str: string | undefined | null): string {
+ if (str == null) {
+ return '';
+ }
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Unescape XML entities back to regular characters.
+ */
+export function unescapeXml(str: string): string {
+ return str
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/>/g, '>')
+ .replace(/</g, '<')
+ .replace(/&/g, '&');
+}
+
+/**
+ * Escape special RegExp characters in a string.
+ */
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Extract the content of a specific XML section.
+ *
+ * Note: This function only matches bare tags without attributes.
+ * Tags with attributes (e.g., ``) are not supported.
+ *
+ * @param xmlContent - The full XML content
+ * @param tagName - The tag name to extract (e.g., 'implemented_features')
+ * @returns The content between the tags, or null if not found
+ */
+export function extractXmlSection(xmlContent: string, tagName: string): string | null {
+ const safeTag = escapeRegExp(tagName);
+ const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'i');
+ const match = xmlContent.match(regex);
+ return match ? match[1] : null;
+}
+
+/**
+ * Extract all values from repeated XML elements.
+ *
+ * Note: This function only matches bare tags without attributes.
+ * Tags with attributes (e.g., ``) are not supported.
+ *
+ * @param xmlContent - The XML content to search
+ * @param tagName - The tag name to extract values from
+ * @returns Array of extracted values (unescaped and trimmed)
+ */
+export function extractXmlElements(xmlContent: string, tagName: string): string[] {
+ const values: string[] = [];
+ const safeTag = escapeRegExp(tagName);
+ const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'g');
+ const matches = xmlContent.matchAll(regex);
+
+ for (const match of matches) {
+ values.push(unescapeXml(match[1].trim()));
+ }
+
+ return values;
+}
diff --git a/libs/spec-parser/tsconfig.json b/libs/spec-parser/tsconfig.json
new file mode 100644
index 00000000..f677f8d5
--- /dev/null
+++ b/libs/spec-parser/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts
index 46244ecd..08db74d8 100644
--- a/libs/types/src/cursor-models.ts
+++ b/libs/types/src/cursor-models.ts
@@ -2,18 +2,19 @@
* Cursor CLI Model IDs
* Reference: https://cursor.com/docs
*
- * IMPORTANT: GPT models use 'cursor-' prefix to distinguish from Codex CLI models
+ * All Cursor model IDs use 'cursor-' prefix for consistent provider routing.
+ * This prevents naming collisions (e.g., cursor-gpt-5.2-codex vs codex-gpt-5.2-codex).
*/
export type CursorModelId =
- | 'auto' // Auto-select best model
- | 'composer-1' // Cursor Composer agent model
- | 'sonnet-4.5' // Claude Sonnet 4.5
- | 'sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
- | 'opus-4.5' // Claude Opus 4.5
- | 'opus-4.5-thinking' // Claude Opus 4.5 with extended thinking
- | 'opus-4.1' // Claude Opus 4.1
- | 'gemini-3-pro' // Gemini 3 Pro
- | 'gemini-3-flash' // Gemini 3 Flash
+ | 'cursor-auto' // Auto-select best model
+ | 'cursor-composer-1' // Cursor Composer agent model
+ | 'cursor-sonnet-4.5' // Claude Sonnet 4.5
+ | 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking
+ | 'cursor-opus-4.5' // Claude Opus 4.5
+ | 'cursor-opus-4.5-thinking' // Claude Opus 4.5 with extended thinking
+ | 'cursor-opus-4.1' // Claude Opus 4.1
+ | 'cursor-gemini-3-pro' // Gemini 3 Pro
+ | 'cursor-gemini-3-flash' // Gemini 3 Flash
| 'cursor-gpt-5.2' // GPT-5.2 via Cursor
| 'cursor-gpt-5.1' // GPT-5.1 via Cursor
| 'cursor-gpt-5.2-high' // GPT-5.2 High via Cursor
@@ -26,7 +27,22 @@ export type CursorModelId =
| 'cursor-gpt-5.2-codex-high' // GPT-5.2 Codex High via Cursor
| 'cursor-gpt-5.2-codex-max' // GPT-5.2 Codex Max via Cursor
| 'cursor-gpt-5.2-codex-max-high' // GPT-5.2 Codex Max High via Cursor
- | 'grok'; // Grok
+ | 'cursor-grok'; // Grok
+
+/**
+ * Legacy Cursor model IDs (without prefix) for migration support
+ */
+export type LegacyCursorModelId =
+ | 'auto'
+ | 'composer-1'
+ | 'sonnet-4.5'
+ | 'sonnet-4.5-thinking'
+ | 'opus-4.5'
+ | 'opus-4.5-thinking'
+ | 'opus-4.1'
+ | 'gemini-3-pro'
+ | 'gemini-3-flash'
+ | 'grok';
/**
* Cursor model metadata
@@ -42,66 +58,67 @@ export interface CursorModelConfig {
/**
* Complete model map for Cursor CLI
+ * All keys use 'cursor-' prefix for consistent provider routing.
*/
export const CURSOR_MODEL_MAP: Record = {
- auto: {
- id: 'auto',
+ 'cursor-auto': {
+ id: 'cursor-auto',
label: 'Auto (Recommended)',
description: 'Automatically selects the best model for each task',
hasThinking: false,
supportsVision: false, // Vision not yet supported by Cursor CLI
},
- 'composer-1': {
- id: 'composer-1',
+ 'cursor-composer-1': {
+ id: 'cursor-composer-1',
label: 'Composer 1',
description: 'Cursor Composer agent model optimized for multi-file edits',
hasThinking: false,
supportsVision: false,
},
- 'sonnet-4.5': {
- id: 'sonnet-4.5',
+ 'cursor-sonnet-4.5': {
+ id: 'cursor-sonnet-4.5',
label: 'Claude Sonnet 4.5',
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
hasThinking: false,
supportsVision: false, // Model supports vision but Cursor CLI doesn't pass images
},
- 'sonnet-4.5-thinking': {
- id: 'sonnet-4.5-thinking',
+ 'cursor-sonnet-4.5-thinking': {
+ id: 'cursor-sonnet-4.5-thinking',
label: 'Claude Sonnet 4.5 (Thinking)',
description: 'Claude Sonnet 4.5 with extended thinking enabled',
hasThinking: true,
supportsVision: false,
},
- 'opus-4.5': {
- id: 'opus-4.5',
+ 'cursor-opus-4.5': {
+ id: 'cursor-opus-4.5',
label: 'Claude Opus 4.5',
description: 'Anthropic Claude Opus 4.5 via Cursor',
hasThinking: false,
supportsVision: false,
},
- 'opus-4.5-thinking': {
- id: 'opus-4.5-thinking',
+ 'cursor-opus-4.5-thinking': {
+ id: 'cursor-opus-4.5-thinking',
label: 'Claude Opus 4.5 (Thinking)',
description: 'Claude Opus 4.5 with extended thinking enabled',
hasThinking: true,
supportsVision: false,
},
- 'opus-4.1': {
- id: 'opus-4.1',
+ 'cursor-opus-4.1': {
+ id: 'cursor-opus-4.1',
label: 'Claude Opus 4.1',
description: 'Anthropic Claude Opus 4.1 via Cursor',
hasThinking: false,
supportsVision: false,
},
- 'gemini-3-pro': {
- id: 'gemini-3-pro',
+ 'cursor-gemini-3-pro': {
+ id: 'cursor-gemini-3-pro',
label: 'Gemini 3 Pro',
description: 'Google Gemini 3 Pro via Cursor',
hasThinking: false,
supportsVision: false,
},
- 'gemini-3-flash': {
- id: 'gemini-3-flash',
+ 'cursor-gemini-3-flash': {
+ id: 'cursor-gemini-3-flash',
label: 'Gemini 3 Flash',
description: 'Google Gemini 3 Flash (faster)',
hasThinking: false,
@@ -191,8 +208,8 @@ export const CURSOR_MODEL_MAP: Record = {
hasThinking: false,
supportsVision: false,
},
- grok: {
- id: 'grok',
+ 'cursor-grok': {
+ id: 'cursor-grok',
label: 'Grok',
description: 'xAI Grok via Cursor',
hasThinking: false,
@@ -200,6 +217,22 @@ export const CURSOR_MODEL_MAP: Record = {
},
};
+/**
+ * Map from legacy model IDs to canonical prefixed IDs
+ */
+export const LEGACY_CURSOR_MODEL_MAP: Record = {
+ auto: 'cursor-auto',
+ 'composer-1': 'cursor-composer-1',
+ 'sonnet-4.5': 'cursor-sonnet-4.5',
+ 'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking',
+ 'opus-4.5': 'cursor-opus-4.5',
+ 'opus-4.5-thinking': 'cursor-opus-4.5-thinking',
+ 'opus-4.1': 'cursor-opus-4.1',
+ 'gemini-3-pro': 'cursor-gemini-3-pro',
+ 'gemini-3-flash': 'cursor-gemini-3-flash',
+ grok: 'cursor-grok',
+};
+
/**
* Helper: Check if model has thinking capability
*/
@@ -254,6 +287,7 @@ export interface GroupedModel {
/**
* Configuration for grouping Cursor models with variants
+ * All variant IDs use 'cursor-' prefix for consistent provider routing.
*/
export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
// GPT-5.2 group (compute levels)
@@ -346,14 +380,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
// Sonnet 4.5 group (thinking mode)
{
- baseId: 'sonnet-4.5-group',
+ baseId: 'cursor-sonnet-4.5-group',
label: 'Claude Sonnet 4.5',
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
variantType: 'thinking',
variants: [
- { id: 'sonnet-4.5', label: 'Standard', description: 'Fast responses' },
+ { id: 'cursor-sonnet-4.5', label: 'Standard', description: 'Fast responses' },
{
- id: 'sonnet-4.5-thinking',
+ id: 'cursor-sonnet-4.5-thinking',
label: 'Thinking',
description: 'Extended reasoning',
badge: 'Reasoning',
@@ -362,14 +396,14 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
// Opus 4.5 group (thinking mode)
{
- baseId: 'opus-4.5-group',
+ baseId: 'cursor-opus-4.5-group',
label: 'Claude Opus 4.5',
description: 'Anthropic Claude Opus 4.5 via Cursor',
variantType: 'thinking',
variants: [
- { id: 'opus-4.5', label: 'Standard', description: 'Fast responses' },
+ { id: 'cursor-opus-4.5', label: 'Standard', description: 'Fast responses' },
{
- id: 'opus-4.5-thinking',
+ id: 'cursor-opus-4.5-thinking',
label: 'Thinking',
description: 'Extended reasoning',
badge: 'Reasoning',
@@ -380,14 +414,15 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
/**
* Cursor models that are not part of any group (standalone)
+ * All IDs use 'cursor-' prefix for consistent provider routing.
*/
export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [
- 'auto',
- 'composer-1',
- 'opus-4.1',
- 'gemini-3-pro',
- 'gemini-3-flash',
- 'grok',
+ 'cursor-auto',
+ 'cursor-composer-1',
+ 'cursor-opus-4.1',
+ 'cursor-gemini-3-pro',
+ 'cursor-gemini-3-flash',
+ 'cursor-grok',
];
/**
diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts
index e352a710..d68f3f66 100644
--- a/libs/types/src/index.ts
+++ b/libs/types/src/index.ts
@@ -77,12 +77,15 @@ export type { ImageData, ImageContentBlock } from './image.js';
// Model types and constants
export {
CLAUDE_MODEL_MAP,
+ CLAUDE_CANONICAL_MAP,
+ LEGACY_CLAUDE_ALIAS_MAP,
CODEX_MODEL_MAP,
CODEX_MODEL_IDS,
REASONING_CAPABLE_MODELS,
supportsReasoningEffort,
getAllCodexModelIds,
DEFAULT_MODELS,
+ type ClaudeCanonicalId,
type ModelAlias,
type CodexModelId,
type AgentModel,
@@ -242,6 +245,18 @@ export {
validateBareModelId,
} from './provider-utils.js';
+// Model migration utilities
+export {
+ isLegacyCursorModelId,
+ isLegacyOpencodeModelId,
+ isLegacyClaudeAlias,
+ migrateModelId,
+ migrateCursorModelIds,
+ migrateOpencodeModelIds,
+ migratePhaseModelEntry,
+ getBareModelIdForCli,
+} from './model-migration.js';
+
// Pipeline types
export type {
PipelineStep,
@@ -297,3 +312,10 @@ export type {
EventReplayHookResult,
} from './event-history.js';
export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js';
+
+// Worktree and PR types
+export type { PRState, WorktreePRInfo } from './worktree.js';
+export { PR_STATES, validatePRState } from './worktree.js';
+
+// Terminal types
+export type { TerminalInfo } from './terminal.js';
diff --git a/libs/types/src/model-migration.ts b/libs/types/src/model-migration.ts
new file mode 100644
index 00000000..49e28c8e
--- /dev/null
+++ b/libs/types/src/model-migration.ts
@@ -0,0 +1,218 @@
+/**
+ * Model ID Migration Utilities
+ *
+ * Provides functions to migrate legacy model IDs to the canonical prefixed format.
+ * This ensures backward compatibility when loading settings from older versions.
+ */
+
+import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js';
+import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js';
+import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js';
+import { LEGACY_OPENCODE_MODEL_MAP, OPENCODE_MODEL_CONFIG_MAP } from './opencode-models.js';
+import type { ClaudeCanonicalId } from './model.js';
+import { LEGACY_CLAUDE_ALIAS_MAP, CLAUDE_CANONICAL_MAP, CLAUDE_MODEL_MAP } from './model.js';
+import type { PhaseModelEntry } from './settings.js';
+
+/**
+ * Check if a string is a legacy Cursor model ID (without prefix)
+ */
+export function isLegacyCursorModelId(id: string): id is LegacyCursorModelId {
+ return id in LEGACY_CURSOR_MODEL_MAP;
+}
+
+/**
+ * Check if a string is a legacy OpenCode model ID (with slash format)
+ */
+export function isLegacyOpencodeModelId(id: string): id is LegacyOpencodeModelId {
+ return id in LEGACY_OPENCODE_MODEL_MAP;
+}
+
+/**
+ * Check if a string is a legacy Claude alias (short name without prefix)
+ */
+export function isLegacyClaudeAlias(id: string): boolean {
+ return id in LEGACY_CLAUDE_ALIAS_MAP;
+}
+
+/**
+ * Migrate a single model ID to canonical format
+ *
+ * Handles:
+ * - Legacy Cursor IDs (e.g., 'auto' -> 'cursor-auto')
+ * - Legacy OpenCode IDs (e.g., 'opencode/big-pickle' -> 'opencode-big-pickle')
+ * - Legacy Claude aliases (e.g., 'sonnet' -> 'claude-sonnet')
+ * - Already-canonical IDs are passed through unchanged
+ *
+ * @param legacyId - The model ID to migrate
+ * @returns The canonical model ID
+ */
+export function migrateModelId(legacyId: string | undefined | null): string {
+ if (!legacyId) {
+ return legacyId as string;
+ }
+
+ // Already has cursor- prefix and is in the map - it's canonical
+ if (legacyId.startsWith('cursor-') && legacyId in CURSOR_MODEL_MAP) {
+ return legacyId;
+ }
+
+ // Legacy Cursor model ID (without prefix)
+ if (isLegacyCursorModelId(legacyId)) {
+ return LEGACY_CURSOR_MODEL_MAP[legacyId];
+ }
+
+ // Already has opencode- prefix - it's canonical
+ if (legacyId.startsWith('opencode-') && legacyId in OPENCODE_MODEL_CONFIG_MAP) {
+ return legacyId;
+ }
+
+ // Legacy OpenCode model ID (with slash format)
+ if (isLegacyOpencodeModelId(legacyId)) {
+ return LEGACY_OPENCODE_MODEL_MAP[legacyId];
+ }
+
+ // Already has claude- prefix and is in canonical map
+ if (legacyId.startsWith('claude-') && legacyId in CLAUDE_CANONICAL_MAP) {
+ return legacyId;
+ }
+
+ // Legacy Claude alias (short name)
+ if (isLegacyClaudeAlias(legacyId)) {
+ return LEGACY_CLAUDE_ALIAS_MAP[legacyId];
+ }
+
+ // Unknown or already canonical - pass through
+ return legacyId;
+}
+
+/**
+ * Migrate an array of Cursor model IDs to canonical format
+ *
+ * @param ids - Array of legacy or canonical Cursor model IDs
+ * @returns Array of canonical Cursor model IDs
+ */
+export function migrateCursorModelIds(ids: string[]): CursorModelId[] {
+ if (!ids || !Array.isArray(ids)) {
+ return [];
+ }
+
+ return ids.map((id) => {
+ // Already canonical
+ if (id.startsWith('cursor-') && id in CURSOR_MODEL_MAP) {
+ return id as CursorModelId;
+ }
+
+ // Legacy ID
+ if (isLegacyCursorModelId(id)) {
+ return LEGACY_CURSOR_MODEL_MAP[id];
+ }
+
+ // Unknown - assume it might be a valid cursor model with prefix
+ if (id.startsWith('cursor-')) {
+ return id as CursorModelId;
+ }
+
+ // Add prefix if not present
+ return `cursor-${id}` as CursorModelId;
+ });
+}
+
+/**
+ * Migrate an array of OpenCode model IDs to canonical format
+ *
+ * @param ids - Array of legacy or canonical OpenCode model IDs
+ * @returns Array of canonical OpenCode model IDs
+ */
+export function migrateOpencodeModelIds(ids: string[]): OpencodeModelId[] {
+ if (!ids || !Array.isArray(ids)) {
+ return [];
+ }
+
+ return ids.map((id) => {
+ // Already canonical (dash format)
+ if (id.startsWith('opencode-') && id in OPENCODE_MODEL_CONFIG_MAP) {
+ return id as OpencodeModelId;
+ }
+
+ // Legacy ID (slash format)
+ if (isLegacyOpencodeModelId(id)) {
+ return LEGACY_OPENCODE_MODEL_MAP[id];
+ }
+
+ // Convert slash to dash format for unknown models
+ if (id.startsWith('opencode/')) {
+ return id.replace('opencode/', 'opencode-') as OpencodeModelId;
+ }
+
+ // Add prefix if not present
+ if (!id.startsWith('opencode-')) {
+ return `opencode-${id}` as OpencodeModelId;
+ }
+
+ return id as OpencodeModelId;
+ });
+}
+
+/**
+ * Migrate a PhaseModelEntry to use canonical model IDs
+ *
+ * @param entry - The phase model entry to migrate
+ * @returns Migrated phase model entry with canonical model ID
+ */
+export function migratePhaseModelEntry(
+ entry: PhaseModelEntry | string | undefined | null
+): PhaseModelEntry {
+ // Handle null/undefined
+ if (!entry) {
+ return { model: 'claude-sonnet' }; // Default
+ }
+
+ // Handle legacy string format
+ if (typeof entry === 'string') {
+ return { model: migrateModelId(entry) };
+ }
+
+ // Handle PhaseModelEntry object
+ return {
+ ...entry,
+ model: migrateModelId(entry.model),
+ };
+}
+
+/**
+ * Get the bare model ID for CLI calls (strip provider prefix)
+ *
+ * When calling provider CLIs, we need to strip the provider prefix:
+ * - 'cursor-auto' -> 'auto' (for Cursor CLI)
+ * - 'cursor-composer-1' -> 'composer-1' (for Cursor CLI)
+ * - 'opencode-big-pickle' -> 'big-pickle' (for OpenCode CLI)
+ *
+ * Note: GPT models via Cursor keep the gpt- part: 'cursor-gpt-5.2' -> 'gpt-5.2'
+ *
+ * @param modelId - The canonical model ID with provider prefix
+ * @returns The bare model ID for CLI usage
+ */
+export function getBareModelIdForCli(modelId: string): string {
+ if (!modelId) return modelId;
+
+ // Cursor models
+ if (modelId.startsWith('cursor-')) {
+ const bareId = modelId.slice(7); // Remove 'cursor-'
+ // For GPT models, keep the gpt- prefix since that's what the CLI expects
+ // e.g., 'cursor-gpt-5.2' -> 'gpt-5.2'
+ return bareId;
+ }
+
+ // OpenCode models - strip prefix
+ if (modelId.startsWith('opencode-')) {
+ return modelId.slice(9); // Remove 'opencode-'
+ }
+
+ // Codex models - strip prefix
+ if (modelId.startsWith('codex-')) {
+ return modelId.slice(6); // Remove 'codex-'
+ }
+
+ // Claude and other models - pass through
+ return modelId;
+}
diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts
index 949938c9..2973a892 100644
--- a/libs/types/src/model.ts
+++ b/libs/types/src/model.ts
@@ -4,12 +4,42 @@
import type { CursorModelId } from './cursor-models.js';
import type { OpencodeModelId } from './opencode-models.js';
+/**
+ * Canonical Claude model IDs with provider prefix
+ * Used for internal storage and consistent provider routing.
+ */
+export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
+
+/**
+ * Canonical Claude model map - maps prefixed IDs to full model strings
+ * Use these IDs for internal storage and routing.
+ */
+export const CLAUDE_CANONICAL_MAP: Record = {
+ 'claude-haiku': 'claude-haiku-4-5-20251001',
+ 'claude-sonnet': 'claude-sonnet-4-5-20250929',
+ 'claude-opus': 'claude-opus-4-5-20251101',
+} as const;
+
+/**
+ * Legacy Claude model aliases (short names) for backward compatibility
+ * These map to the same full model strings as the canonical map.
+ * @deprecated Use CLAUDE_CANONICAL_MAP for new code
+ */
export const CLAUDE_MODEL_MAP: Record = {
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
opus: 'claude-opus-4-5-20251101',
} as const;
+/**
+ * Map from legacy aliases to canonical IDs
+ */
+export const LEGACY_CLAUDE_ALIAS_MAP: Record = {
+ haiku: 'claude-haiku',
+ sonnet: 'claude-sonnet',
+ opus: 'claude-opus',
+} as const;
+
/**
* Codex/OpenAI model identifiers
* Based on OpenAI Codex CLI official models
@@ -62,10 +92,11 @@ export function getAllCodexModelIds(): CodexModelId[] {
/**
* Default models per provider
+ * Uses canonical prefixed IDs for consistent routing.
*/
export const DEFAULT_MODELS = {
claude: 'claude-opus-4-5-20251101',
- cursor: 'auto', // Cursor's recommended default
+ cursor: 'cursor-auto', // Cursor's recommended default (with prefix)
codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model
} as const;
diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts
index 21d5a652..de96f96b 100644
--- a/libs/types/src/opencode-models.ts
+++ b/libs/types/src/opencode-models.ts
@@ -1,9 +1,22 @@
/**
* OpenCode Model IDs
* Models available via OpenCode CLI (opencode models command)
+ *
+ * All OpenCode model IDs use 'opencode-' prefix for consistent provider routing.
+ * This prevents naming collisions and ensures clear provider attribution.
*/
export type OpencodeModelId =
// OpenCode Free Tier Models
+ | 'opencode-big-pickle'
+ | 'opencode-glm-4.7-free'
+ | 'opencode-gpt-5-nano'
+ | 'opencode-grok-code'
+ | 'opencode-minimax-m2.1-free';
+
+/**
+ * Legacy OpenCode model IDs (with slash format) for migration support
+ */
+export type LegacyOpencodeModelId =
| 'opencode/big-pickle'
| 'opencode/glm-4.7-free'
| 'opencode/gpt-5-nano'
@@ -20,16 +33,27 @@ export type OpencodeProvider = 'opencode';
*/
export const OPENCODE_MODEL_MAP: Record = {
// OpenCode free tier aliases
- 'big-pickle': 'opencode/big-pickle',
- pickle: 'opencode/big-pickle',
- 'glm-free': 'opencode/glm-4.7-free',
- 'gpt-nano': 'opencode/gpt-5-nano',
- nano: 'opencode/gpt-5-nano',
- 'grok-code': 'opencode/grok-code',
- grok: 'opencode/grok-code',
- minimax: 'opencode/minimax-m2.1-free',
+ 'big-pickle': 'opencode-big-pickle',
+ pickle: 'opencode-big-pickle',
+ 'glm-free': 'opencode-glm-4.7-free',
+ 'gpt-nano': 'opencode-gpt-5-nano',
+ nano: 'opencode-gpt-5-nano',
+ 'grok-code': 'opencode-grok-code',
+ grok: 'opencode-grok-code',
+ minimax: 'opencode-minimax-m2.1-free',
} as const;
+/**
+ * Map from legacy slash-format model IDs to canonical prefixed IDs
+ */
+export const LEGACY_OPENCODE_MODEL_MAP: Record = {
+ 'opencode/big-pickle': 'opencode-big-pickle',
+ 'opencode/glm-4.7-free': 'opencode-glm-4.7-free',
+ 'opencode/gpt-5-nano': 'opencode-gpt-5-nano',
+ 'opencode/grok-code': 'opencode-grok-code',
+ 'opencode/minimax-m2.1-free': 'opencode-minimax-m2.1-free',
+};
+
/**
* OpenCode model metadata
*/
@@ -44,11 +68,12 @@ export interface OpencodeModelConfig {
/**
* Complete list of OpenCode model configurations
+ * All IDs use 'opencode-' prefix for consistent provider routing.
*/
export const OPENCODE_MODELS: OpencodeModelConfig[] = [
// OpenCode Free Tier Models
{
- id: 'opencode/big-pickle',
+ id: 'opencode-big-pickle',
label: 'Big Pickle',
description: 'OpenCode free tier model - great for general coding',
supportsVision: false,
@@ -56,7 +81,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
- id: 'opencode/glm-4.7-free',
+ id: 'opencode-glm-4.7-free',
label: 'GLM 4.7 Free',
description: 'OpenCode free tier GLM model',
supportsVision: false,
@@ -64,7 +89,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
- id: 'opencode/gpt-5-nano',
+ id: 'opencode-gpt-5-nano',
label: 'GPT-5 Nano',
description: 'OpenCode free tier nano model - fast and lightweight',
supportsVision: false,
@@ -72,7 +97,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
- id: 'opencode/grok-code',
+ id: 'opencode-grok-code',
label: 'Grok Code',
description: 'OpenCode free tier Grok model for coding',
supportsVision: false,
@@ -80,7 +105,7 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [
tier: 'free',
},
{
- id: 'opencode/minimax-m2.1-free',
+ id: 'opencode-minimax-m2.1-free',
label: 'MiniMax M2.1 Free',
description: 'OpenCode free tier MiniMax model',
supportsVision: false,
@@ -104,7 +129,7 @@ export const OPENCODE_MODEL_CONFIG_MAP: Record VS Code > first available) */
defaultEditorCommand: string | null;
+ // Terminal Configuration
+ /** Default external terminal ID for "Open In Terminal" action (null = integrated terminal) */
+ defaultTerminalId: string | null;
+
// Prompt Customization
/** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */
promptCustomization?: PromptCustomization;
@@ -859,34 +867,42 @@ export interface ProjectSettings {
* Value: agent configuration
*/
customSubagents?: Record;
+
+ // Auto Mode Configuration (per-project)
+ /** Whether auto mode is enabled for this project (backend-controlled loop) */
+ automodeEnabled?: boolean;
+ /** Maximum concurrent agents for this project (overrides global maxConcurrency) */
+ maxConcurrentAgents?: number;
}
/**
* Default values and constants
*/
-/** Default phase model configuration - sensible defaults for each task type */
+/** Default phase model configuration - sensible defaults for each task type
+ * Uses canonical prefixed model IDs for consistent routing.
+ */
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
// Quick tasks - use fast models for speed and cost
- enhancementModel: { model: 'sonnet' },
- fileDescriptionModel: { model: 'haiku' },
- imageDescriptionModel: { model: 'haiku' },
+ enhancementModel: { model: 'claude-sonnet' },
+ fileDescriptionModel: { model: 'claude-haiku' },
+ imageDescriptionModel: { model: 'claude-haiku' },
// Validation - use smart models for accuracy
- validationModel: { model: 'sonnet' },
+ validationModel: { model: 'claude-sonnet' },
// Generation - use powerful models for quality
- specGenerationModel: { model: 'opus' },
- featureGenerationModel: { model: 'sonnet' },
- backlogPlanningModel: { model: 'sonnet' },
- projectAnalysisModel: { model: 'sonnet' },
- suggestionsModel: { model: 'sonnet' },
+ specGenerationModel: { model: 'claude-opus' },
+ featureGenerationModel: { model: 'claude-sonnet' },
+ backlogPlanningModel: { model: 'claude-sonnet' },
+ projectAnalysisModel: { model: 'claude-sonnet' },
+ suggestionsModel: { model: 'claude-sonnet' },
// Memory - use fast model for learning extraction (cost-effective)
- memoryExtractionModel: { model: 'haiku' },
+ memoryExtractionModel: { model: 'claude-haiku' },
// Commit messages - use fast model for speed
- commitMessageModel: { model: 'haiku' },
+ commitMessageModel: { model: 'claude-haiku' },
};
/** Current version of the global settings schema */
@@ -936,18 +952,18 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
useWorktrees: true,
defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false,
- defaultFeatureModel: { model: 'opus' },
+ defaultFeatureModel: { model: 'claude-opus' }, // Use canonical ID
muteDoneSound: false,
serverLogLevel: 'info',
enableRequestLogging: true,
enableAiCommitMessages: true,
phaseModels: DEFAULT_PHASE_MODELS,
- enhancementModel: 'sonnet',
- validationModel: 'opus',
- enabledCursorModels: getAllCursorModelIds(),
- cursorDefaultModel: 'auto',
- enabledOpencodeModels: getAllOpencodeModelIds(),
- opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
+ enhancementModel: 'sonnet', // Legacy alias still supported
+ validationModel: 'opus', // Legacy alias still supported
+ enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs
+ cursorDefaultModel: 'cursor-auto', // Use canonical prefixed ID
+ enabledOpencodeModels: getAllOpencodeModelIds(), // Returns prefixed IDs
+ opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Already prefixed
enabledDynamicModelIds: [],
disabledProviders: [],
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
@@ -971,6 +987,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
codexThreadId: undefined,
mcpServers: [],
defaultEditorCommand: null,
+ defaultTerminalId: null,
enableSkills: true,
skillsSources: ['user', 'project'],
enableSubagents: true,
diff --git a/libs/types/src/terminal.ts b/libs/types/src/terminal.ts
new file mode 100644
index 00000000..34b9b6a4
--- /dev/null
+++ b/libs/types/src/terminal.ts
@@ -0,0 +1,15 @@
+/**
+ * Terminal types for the "Open In Terminal" functionality
+ */
+
+/**
+ * Information about an available external terminal
+ */
+export interface TerminalInfo {
+ /** Unique identifier for the terminal (e.g., 'iterm2', 'warp') */
+ id: string;
+ /** Display name of the terminal (e.g., "iTerm2", "Warp") */
+ name: string;
+ /** CLI command or open command to launch the terminal */
+ command: string;
+}
diff --git a/libs/types/src/worktree.ts b/libs/types/src/worktree.ts
new file mode 100644
index 00000000..b81a075d
--- /dev/null
+++ b/libs/types/src/worktree.ts
@@ -0,0 +1,32 @@
+/**
+ * Worktree and PR-related types
+ * Shared across server and UI components
+ */
+
+/** GitHub PR states as returned by the GitHub API (uppercase) */
+export type PRState = 'OPEN' | 'MERGED' | 'CLOSED';
+
+/** Valid PR states for validation */
+export const PR_STATES: readonly PRState[] = ['OPEN', 'MERGED', 'CLOSED'] as const;
+
+/**
+ * Validates a PR state value from external APIs (e.g., GitHub CLI).
+ * Returns the validated state if it matches a known PRState, otherwise returns 'OPEN' as default.
+ * This is safer than type assertions as it handles unexpected values from external APIs.
+ *
+ * @param state - The state string to validate (can be any string)
+ * @returns A valid PRState value
+ */
+export function validatePRState(state: string | undefined | null): PRState {
+ return PR_STATES.find((s) => s === state) ?? 'OPEN';
+}
+
+/** PR information stored in worktree metadata */
+export interface WorktreePRInfo {
+ number: number;
+ url: string;
+ title: string;
+ /** PR state: OPEN, MERGED, or CLOSED */
+ state: PRState;
+ createdAt: string;
+}
diff --git a/package-lock.json b/package-lock.json
index 68307086..32f39ec0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -88,6 +88,7 @@
"license": "SEE LICENSE IN LICENSE",
"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",
@@ -561,6 +562,33 @@
"undici-types": "~6.21.0"
}
},
+ "libs/spec-parser": {
+ "name": "@automaker/spec-parser",
+ "version": "1.0.0",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "@automaker/types": "1.0.0",
+ "fast-xml-parser": "^5.3.3"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.3",
+ "typescript": "5.9.3",
+ "vitest": "4.0.16"
+ },
+ "engines": {
+ "node": ">=22.0.0 <23.0.0"
+ }
+ },
+ "libs/spec-parser/node_modules/@types/node": {
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
"libs/types": {
"name": "@automaker/types",
"version": "1.0.0",
@@ -656,6 +684,10 @@
"resolved": "apps/server",
"link": true
},
+ "node_modules/@automaker/spec-parser": {
+ "resolved": "libs/spec-parser",
+ "link": true
+ },
"node_modules/@automaker/types": {
"resolved": "libs/types",
"link": true
@@ -6158,7 +6190,6 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -6168,7 +6199,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -8379,7 +8410,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/d3-color": {
@@ -9726,6 +9756,24 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/fast-xml-parser": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
+ "integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -11255,6 +11303,7 @@
"os": [
"android"
],
+ "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11318,6 +11367,7 @@
"os": [
"freebsd"
],
+ "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -14879,6 +14929,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strnum": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
diff --git a/package.json b/package.json
index 1c884bc5..96c9bf1e 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,7 @@
"dev:docker:rebuild": "docker compose build --no-cache && docker compose up",
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
"build": "npm run build:packages && npm run build --workspace=apps/ui",
- "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
+ "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
"build:server": "npm run build:packages && npm run build --workspace=apps/server",
"build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui",
"build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui",
diff --git a/start-automaker.sh b/start-automaker.sh
index a2d3e54c..ef7b1172 100755
--- a/start-automaker.sh
+++ b/start-automaker.sh
@@ -1075,7 +1075,8 @@ case $MODE in
export TEST_PORT="$WEB_PORT"
export VITE_SERVER_URL="http://$HOSTNAME:$SERVER_PORT"
export PORT="$SERVER_PORT"
- export CORS_ORIGIN="http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT"
+ export DATA_DIR="$SCRIPT_DIR/data"
+ export CORS_ORIGIN="http://localhost:$WEB_PORT,http://$HOSTNAME:$WEB_PORT,http://127.0.0.1:$WEB_PORT"
export VITE_APP_MODE="1"
if [ "$PRODUCTION_MODE" = true ]; then
diff --git a/test/agent-session-test-115699-vyk2nk2/test-project-1768743000887/package.json b/test/agent-session-test-115699-vyk2nk2/test-project-1768743000887/package.json
new file mode 100644
index 00000000..68258c5b
--- /dev/null
+++ b/test/agent-session-test-115699-vyk2nk2/test-project-1768743000887/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "test-project-1768743000887",
+ "version": "1.0.0"
+}
diff --git a/test/feature-backlog-test-114171-aysp86y/test-project-1768742910934/package.json b/test/feature-backlog-test-114171-aysp86y/test-project-1768742910934/package.json
new file mode 100644
index 00000000..4ea81845
--- /dev/null
+++ b/test/feature-backlog-test-114171-aysp86y/test-project-1768742910934/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "test-project-1768742910934",
+ "version": "1.0.0"
+}