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/SECURITY_TODO.md b/SECURITY_TODO.md
new file mode 100644
index 00000000..f12c02a3
--- /dev/null
+++ b/SECURITY_TODO.md
@@ -0,0 +1,300 @@
+# Security Audit Findings - v0.13.0rc Branch
+
+**Date:** $(date)
+**Audit Type:** Git diff security review against v0.13.0rc branch
+**Status:** ⚠️ Security vulnerabilities found - requires fixes before release
+
+## Executive Summary
+
+No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release.
+
+---
+
+## 🔴 Critical Security Issues
+
+### 1. Command Injection in Merge Handler
+
+**File:** `apps/server/src/routes/worktree/routes/merge.ts`
+**Lines:** 43, 54, 65-66, 93
+**Severity:** CRITICAL
+
+**Issue:**
+User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks.
+
+**Vulnerable Code:**
+
+```typescript
+// Line 43 - branchName not validated
+await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
+
+// Line 54 - mergeTo not validated
+await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
+
+// Lines 65-66 - branchName and message not validated
+const mergeCmd = options?.squash
+ ? `git merge --squash ${branchName}`
+ : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
+
+// Line 93 - message not sanitized
+await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
+ cwd: projectPath,
+});
+```
+
+**Attack Vector:**
+An attacker could inject shell commands via branch names or commit messages:
+
+- Branch name: `main; rm -rf /`
+- Commit message: `"; malicious_command; "`
+
+**Fix Required:**
+
+1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use
+2. Sanitize commit messages or use `execGitCommand` with proper escaping
+3. Replace `execAsync` template literals with `execGitCommand` array-based calls
+
+**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls.
+
+---
+
+### 2. Command Injection in Push Handler
+
+**File:** `apps/server/src/routes/worktree/routes/push.ts`
+**Lines:** 44, 49
+**Severity:** CRITICAL
+
+**Issue:**
+User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation.
+
+**Vulnerable Code:**
+
+```typescript
+// Line 38 - remote defaults to 'origin' but not validated
+const targetRemote = remote || 'origin';
+
+// Lines 44, 49 - targetRemote and branchName not validated
+await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
+ cwd: worktreePath,
+});
+await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
+ cwd: worktreePath,
+});
+```
+
+**Attack Vector:**
+An attacker could inject commands via the remote name:
+
+- Remote: `origin; malicious_command; #`
+
+**Fix Required:**
+
+1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only)
+2. Validate `branchName` before use (even though it comes from git output)
+3. Use `execGitCommand` with array arguments instead of template literals
+
+---
+
+### 3. Unsafe Environment Variable Export in Shell Script
+
+**File:** `start-automaker.sh`
+**Lines:** 5068, 5085
+**Severity:** CRITICAL
+
+**Issue:**
+Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters.
+
+**Vulnerable Code:**
+
+```bash
+export $(grep -v '^#' .env | xargs)
+```
+
+**Attack Vector:**
+If `.env` file contains malicious content with spaces, special characters, or code, it could be executed:
+
+- `.env` entry: `VAR="value; malicious_command"`
+- Could lead to code execution during startup
+
+**Fix Required:**
+Replace with safer parsing method:
+
+```bash
+# Safer approach
+set -a
+source <(grep -v '^#' .env | sed 's/^/export /')
+set +a
+
+# Or even safer - validate each line
+while IFS= read -r line; do
+ [[ "$line" =~ ^[[:space:]]*# ]] && continue
+ [[ -z "$line" ]] && continue
+ if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
+ export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}"
+ fi
+done < .env
+```
+
+---
+
+## 🟡 Moderate Security Concerns
+
+### 4. Inconsistent Use of Secure Command Execution
+
+**Issue:**
+The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals.
+
+**Files Affected:**
+
+- `apps/server/src/routes/worktree/routes/merge.ts`
+- `apps/server/src/routes/worktree/routes/push.ts`
+
+**Recommendation:**
+
+- Audit all `execAsync` calls with template literals
+- Replace with `execGitCommand` where possible
+- Document when `execAsync` is acceptable (only with fully validated inputs)
+
+---
+
+### 5. Missing Input Validation
+
+**Issues:**
+
+1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated
+2. Commit messages in `merge.ts` aren't sanitized before use in shell commands
+3. `worktreePath` validation relies on middleware but should be double-checked
+
+**Recommendation:**
+
+- Add validation functions for remote names
+- Sanitize commit messages (remove shell metacharacters)
+- Add defensive validation even when middleware exists
+
+---
+
+## ✅ Positive Security Findings
+
+1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff
+2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns
+3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected
+4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places
+5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220)
+
+---
+
+## 📋 Action Items
+
+### Immediate (Before Release)
+
+- [ ] **Fix command injection in `merge.ts`**
+ - [ ] Validate `branchName` with `isValidBranchName()` before line 43
+ - [ ] Validate `mergeTo` with `isValidBranchName()` before line 54
+ - [ ] Sanitize commit messages or use `execGitCommand` for merge commands
+ - [ ] Replace `execAsync` template literals with `execGitCommand` array calls
+
+- [ ] **Fix command injection in `push.ts`**
+ - [ ] Add validation function for remote names
+ - [ ] Validate `targetRemote` before use
+ - [ ] Validate `branchName` before use (defensive programming)
+ - [ ] Replace `execAsync` template literals with `execGitCommand`
+
+- [ ] **Fix shell script security issue**
+ - [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing
+ - [ ] Add validation for `.env` file contents
+ - [ ] Test with edge cases (spaces, special chars, quotes)
+
+### Short-term (Next Sprint)
+
+- [ ] **Audit all `execAsync` calls**
+ - [ ] Create inventory of all `execAsync` calls with template literals
+ - [ ] Replace with `execGitCommand` where possible
+ - [ ] Document exceptions and why they're safe
+
+- [ ] **Add input validation utilities**
+ - [ ] Create `isValidRemoteName()` function
+ - [ ] Create `sanitizeCommitMessage()` function
+ - [ ] Add validation for all user-controlled inputs
+
+- [ ] **Security testing**
+ - [ ] Add unit tests for command injection prevention
+ - [ ] Add integration tests with malicious inputs
+ - [ ] Test shell script with malicious `.env` files
+
+### Long-term (Security Hardening)
+
+- [ ] **Code review process**
+ - [ ] Add security checklist for PR reviews
+ - [ ] Require security review for shell command execution changes
+ - [ ] Add automated security scanning
+
+- [ ] **Documentation**
+ - [ ] Document secure coding practices for shell commands
+ - [ ] Create security guidelines for contributors
+ - [ ] Add security section to CONTRIBUTING.md
+
+---
+
+## 🔍 Testing Recommendations
+
+### Command Injection Tests
+
+```typescript
+// Test cases for merge.ts
+describe('merge handler security', () => {
+ it('should reject branch names with shell metacharacters', () => {
+ // Test: branchName = "main; rm -rf /"
+ // Expected: Validation error, command not executed
+ });
+
+ it('should sanitize commit messages', () => {
+ // Test: message = '"; malicious_command; "'
+ // Expected: Sanitized or rejected
+ });
+});
+
+// Test cases for push.ts
+describe('push handler security', () => {
+ it('should reject remote names with shell metacharacters', () => {
+ // Test: remote = "origin; malicious_command; #"
+ // Expected: Validation error, command not executed
+ });
+});
+```
+
+### Shell Script Tests
+
+```bash
+# Test with malicious .env content
+echo 'VAR="value; echo PWNED"' > test.env
+# Expected: Should not execute the command
+
+# Test with spaces in values
+echo 'VAR="value with spaces"' > test.env
+# Expected: Should handle correctly
+
+# Test with special characters
+echo 'VAR="value\$with\$dollars"' > test.env
+# Expected: Should handle correctly
+```
+
+---
+
+## 📚 References
+
+- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
+- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns)
+- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices)
+
+---
+
+## Notes
+
+- All findings are based on code diff analysis
+- No runtime testing was performed
+- Assumes attacker has access to API endpoints (authenticated or unauthenticated)
+- Fixes should be tested thoroughly before deployment
+
+---
+
+**Last Updated:** $(date)
+**Next Review:** After fixes are implemented
diff --git a/TODO.md b/TODO.md
index 3771806b..4ea7cf34 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,6 +2,14 @@
- Setting the default model does not seem like it works.
+# Performance (completed)
+
+- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
+- [x] Render containment on heavy scroll regions (kanban columns, chat history)
+- [x] Reduce blur/shadow effects when lists get large
+- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
+- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
+
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index d90c7a36..3c90fd38 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
@@ -222,10 +249,27 @@ notificationService.setEventEmitter(events);
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
-eventHookService.initialize(events, settingsService, eventHistoryService);
+eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
// 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..60cb2d58 100644
--- a/apps/server/src/lib/auth.ts
+++ b/apps/server/src/lib/auth.ts
@@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session';
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
+/**
+ * Check if an environment variable is set to 'true'
+ */
+function isEnvTrue(envVar: string | undefined): boolean {
+ return envVar === 'true';
+}
+
// Session store - persisted to file for survival across server restarts
const validSessions = new Map();
@@ -130,21 +137,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') {
+if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
+ const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
+ const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
+
+ // Build box lines with exact padding
+ 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 +353,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,10 +377,17 @@ 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 {
+ // Allow disabling auth for local/trusted networks
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
+ next();
+ return;
+ }
+
const result = checkAuthentication(
req.headers as Record,
req.query as Record,
@@ -384,9 +433,10 @@ export function isAuthEnabled(): boolean {
* Get authentication status for health endpoint
*/
export function getAuthStatus(): { enabled: boolean; method: string } {
+ const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
return {
- enabled: true,
- method: 'api_key_or_session',
+ enabled: !disabled,
+ method: disabled ? 'disabled' : 'api_key_or_session',
};
}
@@ -394,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
* Check if a request is authenticated (for status endpoint)
*/
export function isRequestAuthenticated(req: Request): boolean {
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
const result = checkAuthentication(
req.headers as Record,
req.query as Record,
@@ -411,5 +462,6 @@ export function checkRawAuthentication(
query: Record,
cookies: Record
): boolean {
+ if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
return checkAuthentication(headers, query, cookies).authenticated;
}
diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts
index a1bdc4e5..2f930ef3 100644
--- a/apps/server/src/lib/settings-helpers.ts
+++ b/apps/server/src/lib/settings-helpers.ts
@@ -5,7 +5,17 @@
import type { SettingsService } from '../services/settings-service.js';
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
import { createLogger } from '@automaker/utils';
-import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
+import type {
+ MCPServerConfig,
+ McpServerConfig,
+ PromptCustomization,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
+ PhaseModelKey,
+ PhaseModelEntry,
+ Credentials,
+} from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import {
mergeAutoModePrompts,
mergeAgentPrompts,
@@ -345,3 +355,376 @@ export async function getCustomSubagents(
return Object.keys(merged).length > 0 ? merged : undefined;
}
+
+/** Result from getActiveClaudeApiProfile */
+export interface ActiveClaudeApiProfileResult {
+ /** The active profile, or undefined if using direct Anthropic API */
+ profile: ClaudeApiProfile | undefined;
+ /** Credentials for resolving 'credentials' apiKeySource */
+ credentials: import('@automaker/types').Credentials | undefined;
+}
+
+/**
+ * Get the active Claude API profile and credentials from settings.
+ * Checks project settings first for per-project overrides, then falls back to global settings.
+ * Returns both the profile and credentials for resolving 'credentials' apiKeySource.
+ *
+ * @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
+ * This function is kept for backward compatibility during migration.
+ *
+ * @param settingsService - Optional settings service instance
+ * @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
+ * @param projectPath - Optional project path for per-project override
+ * @returns Promise resolving to object with profile and credentials
+ */
+export async function getActiveClaudeApiProfile(
+ settingsService?: SettingsService | null,
+ logPrefix = '[SettingsHelper]',
+ projectPath?: string
+): Promise {
+ if (!settingsService) {
+ return { profile: undefined, credentials: undefined };
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const profiles = globalSettings.claudeApiProfiles || [];
+
+ // Check for project-level override first
+ let activeProfileId: string | null | undefined;
+ let isProjectOverride = false;
+
+ if (projectPath) {
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ // undefined = use global, null = explicit no profile, string = specific profile
+ if (projectSettings.activeClaudeApiProfileId !== undefined) {
+ activeProfileId = projectSettings.activeClaudeApiProfileId;
+ isProjectOverride = true;
+ }
+ }
+
+ // Fall back to global if project doesn't specify
+ if (activeProfileId === undefined && !isProjectOverride) {
+ activeProfileId = globalSettings.activeClaudeApiProfileId;
+ }
+
+ // No active profile selected - use direct Anthropic API
+ if (!activeProfileId) {
+ if (isProjectOverride && activeProfileId === null) {
+ logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
+ }
+ return { profile: undefined, credentials };
+ }
+
+ // Find the active profile by ID
+ const activeProfile = profiles.find((p) => p.id === activeProfileId);
+
+ if (activeProfile) {
+ const overrideSuffix = isProjectOverride ? ' (project override)' : '';
+ logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
+ return { profile: activeProfile, credentials };
+ } else {
+ logger.warn(
+ `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
+ );
+ return { profile: undefined, credentials };
+ }
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
+ return { profile: undefined, credentials: undefined };
+ }
+}
+
+// ============================================================================
+// New Provider System Helpers
+// ============================================================================
+
+/** Result from getProviderById */
+export interface ProviderByIdResult {
+ /** The provider, or undefined if not found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** Credentials for resolving 'credentials' apiKeySource */
+ credentials: Credentials | undefined;
+}
+
+/**
+ * Get a ClaudeCompatibleProvider by its ID.
+ * Returns the provider configuration and credentials for API key resolution.
+ *
+ * @param providerId - The provider ID to look up
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to object with provider and credentials
+ */
+export async function getProviderById(
+ providerId: string,
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ const provider = providers.find((p) => p.id === providerId);
+
+ if (provider) {
+ if (provider.enabled === false) {
+ logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
+ } else {
+ logger.debug(`${logPrefix} Found provider: ${provider.name}`);
+ }
+ return { provider, credentials };
+ } else {
+ logger.warn(`${logPrefix} Provider not found: ${providerId}`);
+ return { provider: undefined, credentials };
+ }
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to load provider by ID:`, error);
+ return { provider: undefined, credentials: undefined };
+ }
+}
+
+/** Result from getPhaseModelWithOverrides */
+export interface PhaseModelWithOverridesResult {
+ /** The resolved phase model entry */
+ phaseModel: PhaseModelEntry;
+ /** Whether a project override was applied */
+ isProjectOverride: boolean;
+ /** The provider if providerId is set and found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** Credentials for API key resolution */
+ credentials: Credentials | undefined;
+}
+
+/**
+ * Get the phase model configuration for a specific phase, applying project overrides if available.
+ * Also resolves the provider if the phase model has a providerId.
+ *
+ * @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
+ * @param settingsService - Optional settings service instance (returns defaults if undefined)
+ * @param projectPath - Optional project path for checking overrides
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to phase model with provider info
+ */
+export async function getPhaseModelWithOverrides(
+ phase: PhaseModelKey,
+ settingsService?: SettingsService | null,
+ projectPath?: string,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ // Handle undefined settingsService gracefully
+ if (!settingsService) {
+ logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`);
+ return {
+ phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' },
+ isProjectOverride: false,
+ provider: undefined,
+ credentials: undefined,
+ };
+ }
+
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const globalPhaseModels = globalSettings.phaseModels || {};
+
+ // Start with global phase model
+ let phaseModel = globalPhaseModels[phase];
+ let isProjectOverride = false;
+
+ // Check for project override
+ if (projectPath) {
+ const projectSettings = await settingsService.getProjectSettings(projectPath);
+ const projectOverrides = projectSettings.phaseModelOverrides || {};
+
+ if (projectOverrides[phase]) {
+ phaseModel = projectOverrides[phase];
+ isProjectOverride = true;
+ logger.debug(`${logPrefix} Using project override for ${phase}`);
+ }
+ }
+
+ // If no phase model found, use per-phase default
+ if (!phaseModel) {
+ phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
+ logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
+ }
+
+ // Resolve provider if providerId is set
+ let provider: ClaudeCompatibleProvider | undefined;
+ if (phaseModel.providerId) {
+ const providers = globalSettings.claudeCompatibleProviders || [];
+ provider = providers.find((p) => p.id === phaseModel.providerId);
+
+ if (provider) {
+ if (provider.enabled === false) {
+ logger.warn(
+ `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
+ );
+ provider = undefined;
+ } else {
+ logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
+ }
+ } else {
+ logger.warn(
+ `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
+ );
+ }
+ }
+
+ return {
+ phaseModel,
+ isProjectOverride,
+ provider,
+ credentials,
+ };
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
+ // Return a safe default
+ return {
+ phaseModel: { model: 'sonnet' },
+ isProjectOverride: false,
+ provider: undefined,
+ credentials: undefined,
+ };
+ }
+}
+
+/** Result from getProviderByModelId */
+export interface ProviderByModelIdResult {
+ /** The provider that contains this model, or undefined if not found */
+ provider: ClaudeCompatibleProvider | undefined;
+ /** The model configuration if found */
+ modelConfig: import('@automaker/types').ProviderModel | undefined;
+ /** Credentials for API key resolution */
+ credentials: Credentials | undefined;
+ /** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
+ resolvedModel: string | undefined;
+}
+
+/**
+ * Find a ClaudeCompatibleProvider by one of its model IDs.
+ * Searches through all enabled providers to find one that contains the specified model.
+ * This is useful when you have a model string from the UI but need the provider config.
+ *
+ * Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
+ * when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
+ *
+ * @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to object with provider, model config, credentials, and resolved model
+ */
+export async function getProviderByModelId(
+ modelId: string,
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const credentials = await settingsService.getCredentials();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ // Search through all enabled providers for this model
+ for (const provider of providers) {
+ // Skip disabled providers
+ if (provider.enabled === false) {
+ continue;
+ }
+
+ // Check if this provider has the model
+ const modelConfig = provider.models?.find(
+ (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
+ );
+
+ if (modelConfig) {
+ logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
+
+ // Resolve the mapped Claude model if specified
+ let resolvedModel: string | undefined;
+ if (modelConfig.mapsToClaudeModel) {
+ // Import resolveModelString to convert alias to full model ID
+ const { resolveModelString } = await import('@automaker/model-resolver');
+ resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
+ logger.info(
+ `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
+ );
+ }
+
+ return { provider, modelConfig, credentials, resolvedModel };
+ }
+ }
+
+ // Model not found in any provider
+ logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
+ return {
+ provider: undefined,
+ modelConfig: undefined,
+ credentials: undefined,
+ resolvedModel: undefined,
+ };
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
+ return {
+ provider: undefined,
+ modelConfig: undefined,
+ credentials: undefined,
+ resolvedModel: undefined,
+ };
+ }
+}
+
+/**
+ * Get all enabled provider models for use in model dropdowns.
+ * Returns models from all enabled ClaudeCompatibleProviders.
+ *
+ * @param settingsService - Settings service instance
+ * @param logPrefix - Prefix for log messages
+ * @returns Promise resolving to array of provider models with their provider info
+ */
+export async function getAllProviderModels(
+ settingsService: SettingsService,
+ logPrefix = '[SettingsHelper]'
+): Promise<
+ Array<{
+ providerId: string;
+ providerName: string;
+ model: import('@automaker/types').ProviderModel;
+ }>
+> {
+ try {
+ const globalSettings = await settingsService.getGlobalSettings();
+ const providers = globalSettings.claudeCompatibleProviders || [];
+
+ const allModels: Array<{
+ providerId: string;
+ providerName: string;
+ model: import('@automaker/types').ProviderModel;
+ }> = [];
+
+ for (const provider of providers) {
+ // Skip disabled providers
+ if (provider.enabled === false) {
+ continue;
+ }
+
+ for (const model of provider.models || []) {
+ allModels.push({
+ providerId: provider.id,
+ providerName: provider.name,
+ model,
+ });
+ }
+ }
+
+ logger.debug(
+ `${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
+ );
+ return allModels;
+ } catch (error) {
+ logger.error(`${logPrefix} Failed to get all provider models:`, error);
+ return [];
+ }
+}
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/claude-provider.ts b/apps/server/src/providers/claude-provider.ts
index f8a31d81..cfb59093 100644
--- a/apps/server/src/providers/claude-provider.ts
+++ b/apps/server/src/providers/claude-provider.ts
@@ -10,7 +10,21 @@ import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
-import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
+import {
+ getThinkingTokenBudget,
+ validateBareModelId,
+ type ClaudeApiProfile,
+ type ClaudeCompatibleProvider,
+ type Credentials,
+} from '@automaker/types';
+
+/**
+ * ProviderConfig - Union type for provider configuration
+ *
+ * Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider.
+ * Both share the same connection settings structure.
+ */
+type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
import type {
ExecuteOptions,
ProviderMessage,
@@ -21,9 +35,19 @@ import type {
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
+ // Authentication
'ANTHROPIC_API_KEY',
- 'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
+ // Endpoint configuration
+ 'ANTHROPIC_BASE_URL',
+ 'API_TIMEOUT_MS',
+ // Model mappings
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
+ // Traffic control
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
+ // System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
@@ -33,16 +57,132 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
+// System vars are always passed from process.env regardless of profile
+const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
+
/**
- * Build environment for the SDK with only explicitly allowed variables
+ * Check if the config is a ClaudeCompatibleProvider (new system)
+ * by checking for the 'models' array property
*/
-function buildEnv(): Record {
+function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider {
+ return 'models' in config && Array.isArray(config.models);
+}
+
+/**
+ * Build environment for the SDK with only explicitly allowed variables.
+ * When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
+ * When no provider is provided, uses direct Anthropic API settings from process.env.
+ *
+ * Supports both:
+ * - ClaudeCompatibleProvider (new system with models[] array)
+ * - ClaudeApiProfile (legacy system with modelMappings)
+ *
+ * @param providerConfig - Optional provider configuration for alternative endpoint
+ * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
+ */
+function buildEnv(
+ providerConfig?: ProviderConfig,
+ credentials?: Credentials
+): Record {
const env: Record = {};
- for (const key of ALLOWED_ENV_VARS) {
+
+ if (providerConfig) {
+ // Use provider configuration (clean switch - don't inherit non-system vars from process.env)
+ logger.debug('[buildEnv] Using provider configuration:', {
+ name: providerConfig.name,
+ baseUrl: providerConfig.baseUrl,
+ apiKeySource: providerConfig.apiKeySource ?? 'inline',
+ isNewProvider: isClaudeCompatibleProvider(providerConfig),
+ });
+
+ // Resolve API key based on source strategy
+ let apiKey: string | undefined;
+ const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
+
+ switch (source) {
+ case 'inline':
+ apiKey = providerConfig.apiKey;
+ break;
+ case 'env':
+ apiKey = process.env.ANTHROPIC_API_KEY;
+ break;
+ case 'credentials':
+ apiKey = credentials?.apiKeys?.anthropic;
+ break;
+ }
+
+ // Warn if no API key found
+ if (!apiKey) {
+ logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
+ }
+
+ // Authentication
+ if (providerConfig.useAuthToken) {
+ env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
+ } else {
+ env['ANTHROPIC_API_KEY'] = apiKey;
+ }
+
+ // Endpoint configuration
+ env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl;
+ logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`);
+
+ if (providerConfig.timeoutMs) {
+ env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
+ }
+
+ // Model mappings - only for legacy ClaudeApiProfile
+ // For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
+ if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
+ if (providerConfig.modelMappings.haiku) {
+ env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
+ }
+ if (providerConfig.modelMappings.sonnet) {
+ env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
+ }
+ if (providerConfig.modelMappings.opus) {
+ env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
+ }
+ }
+
+ // Traffic control
+ if (providerConfig.disableNonessentialTraffic) {
+ env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
+ }
+ } else {
+ // Use direct Anthropic API - pass through credentials or environment variables
+ // This supports:
+ // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
+ // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
+ // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
+ //
+ // Priority: credentials file (UI settings) -> environment variable
+ // Note: Only auth and endpoint vars are passed. Model mappings and traffic
+ // control are NOT passed (those require a profile for explicit configuration).
+ if (credentials?.apiKeys?.anthropic) {
+ env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
+ } else if (process.env.ANTHROPIC_API_KEY) {
+ env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
+ }
+ // If using Claude Max plan via CLI auth, the SDK handles auth automatically
+ // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
+ // unless it was explicitly set in process.env (rare edge case).
+ if (process.env.ANTHROPIC_AUTH_TOKEN) {
+ env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
+ }
+ // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
+ if (process.env.ANTHROPIC_BASE_URL) {
+ env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
+ }
+ }
+
+ // Always add system vars from process.env
+ for (const key of SYSTEM_ENV_VARS) {
if (process.env[key]) {
env[key] = process.env[key];
}
}
+
return env;
}
@@ -70,8 +210,15 @@ export class ClaudeProvider extends BaseProvider {
conversationHistory,
sdkSessionId,
thinkingLevel,
+ claudeApiProfile,
+ claudeCompatibleProvider,
+ credentials,
} = options;
+ // Determine which provider config to use
+ // claudeCompatibleProvider takes precedence over claudeApiProfile
+ const providerConfig = claudeCompatibleProvider || claudeApiProfile;
+
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
@@ -82,7 +229,9 @@ export class ClaudeProvider extends BaseProvider {
maxTurns,
cwd,
// Pass only explicitly allowed environment variables to SDK
- env: buildEnv(),
+ // When a provider is active, uses provider settings (clean switch)
+ // When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
+ env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
@@ -127,6 +276,18 @@ export class ClaudeProvider extends BaseProvider {
promptPayload = prompt;
}
+ // Log the environment being passed to the SDK for debugging
+ const envForSdk = sdkOptions.env as Record;
+ logger.debug('[ClaudeProvider] SDK Configuration:', {
+ model: sdkOptions.model,
+ baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)',
+ hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'],
+ hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'],
+ providerName: providerConfig?.name || '(direct Anthropic)',
+ maxTurns: sdkOptions.maxTurns,
+ maxThinkingTokens: sdkOptions.maxThinkingTokens,
+ });
+
// Execute via Claude Agent SDK
try {
const stream = query({ prompt: promptPayload, options: sdkOptions });
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/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts
index 6babb978..d2fa13d9 100644
--- a/apps/server/src/providers/opencode-provider.ts
+++ b/apps/server/src/providers/opencode-provider.ts
@@ -25,7 +25,6 @@ import type {
InstallationStatus,
ContentBlock,
} from '@automaker/types';
-import { stripProviderPrefix } from '@automaker/types';
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
@@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider {
args.push('--format', 'json');
// Handle model selection
- // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
+ // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
+ // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
if (options.model) {
- const model = stripProviderPrefix(options.model);
- args.push('--model', model);
+ // Strip opencode- prefix if present, then ensure slash format
+ const model = options.model.startsWith('opencode-')
+ ? options.model.slice('opencode-'.length)
+ : options.model;
+
+ // If model has slash, it's already provider/model format; otherwise prepend opencode/
+ const cliModel = model.includes('/') ? model : `opencode/${model}`;
+
+ args.push('--model', cliModel);
}
// Note: OpenCode reads from stdin automatically when input is piped
@@ -1035,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
'lm studio': 'lmstudio',
lmstudio: 'lmstudio',
opencode: 'opencode',
- 'z.ai coding plan': 'z-ai',
+ 'z.ai coding plan': 'zai-coding-plan',
'z.ai': 'z-ai',
};
diff --git a/apps/server/src/providers/simple-query-service.ts b/apps/server/src/providers/simple-query-service.ts
index 5882b96f..85c25235 100644
--- a/apps/server/src/providers/simple-query-service.ts
+++ b/apps/server/src/providers/simple-query-service.ts
@@ -20,6 +20,9 @@ import type {
ContentBlock,
ThinkingLevel,
ReasoningEffort,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
+ Credentials,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
@@ -54,6 +57,18 @@ export interface SimpleQueryOptions {
readOnly?: boolean;
/** Setting sources for CLAUDE.md loading */
settingSources?: Array<'user' | 'project' | 'local'>;
+ /**
+ * Active Claude API profile for alternative endpoint configuration
+ * @deprecated Use claudeCompatibleProvider instead
+ */
+ claudeApiProfile?: ClaudeApiProfile;
+ /**
+ * Claude-compatible provider for alternative endpoint configuration.
+ * Takes precedence over claudeApiProfile if both are set.
+ */
+ claudeCompatibleProvider?: ClaudeCompatibleProvider;
+ /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */
+ credentials?: Credentials;
}
/**
@@ -125,6 +140,9 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {
diff --git a/apps/server/src/routes/app-spec/generate-spec.ts b/apps/server/src/routes/app-spec/generate-spec.ts
index 4fa3d11a..0f826d76 100644
--- a/apps/server/src/routes/app-spec/generate-spec.ts
+++ b/apps/server/src/routes/app-spec/generate-spec.ts
@@ -16,7 +16,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
-import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
const logger = createLogger('SpecRegeneration');
@@ -92,13 +96,26 @@ ${prompts.appSpec.structuredSpecInstructions}`;
'[SpecRegeneration]'
);
- // Get model from phase settings
- const settings = await settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = settingsService
+ ? await getPhaseModelWithOverrides(
+ 'specGenerationModel',
+ settingsService,
+ projectPath,
+ '[SpecRegeneration]'
+ )
+ : {
+ phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
+ provider: undefined,
+ credentials: undefined,
+ };
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
- logger.info('Using model:', model);
+ logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
let responseText = '';
let structuredOutput: SpecOutput | null = null;
@@ -132,6 +149,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Spec generation only reads code, we write the spec ourselves
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
diff --git a/apps/server/src/routes/app-spec/sync-spec.ts b/apps/server/src/routes/app-spec/sync-spec.ts
index 98352855..af5139dd 100644
--- a/apps/server/src/routes/app-spec/sync-spec.ts
+++ b/apps/server/src/routes/app-spec/sync-spec.ts
@@ -15,7 +15,10 @@ import { resolvePhaseModel } from '@automaker/model-resolver';
import { streamingQuery } from '../../providers/simple-query-service.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
-import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
import {
extractImplementedFeatures,
@@ -152,11 +155,27 @@ export async function syncSpec(
'[SpecSync]'
);
- const settings = await settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = settingsService
+ ? await getPhaseModelWithOverrides(
+ 'specGenerationModel',
+ settingsService,
+ projectPath,
+ '[SpecSync]'
+ )
+ : {
+ phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
+ provider: undefined,
+ credentials: undefined,
+ };
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
+ logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
+
// Use AI to analyze tech stack
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
@@ -185,6 +204,8 @@ Return ONLY this JSON format, no other text:
thinkingLevel,
readOnly: true,
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
},
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/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts
index 1bec9368..2d53c8e5 100644
--- a/apps/server/src/routes/auto-mode/routes/run-feature.ts
+++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts
@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
return;
}
+ // Check per-worktree capacity before starting
+ const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
+ if (!capacity.hasCapacity) {
+ const worktreeDesc = capacity.branchName
+ ? `worktree "${capacity.branchName}"`
+ : 'main worktree';
+ res.status(429).json({
+ success: false,
+ error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
+ details: {
+ currentAgents: capacity.currentAgents,
+ maxAgents: capacity.maxAgents,
+ branchName: capacity.branchName,
+ },
+ });
+ return;
+ }
+
// Start execution in background
// executeFeature derives workDir from feature.branchName
autoModeService
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..3ace816d
--- /dev/null
+++ b/apps/server/src/routes/auto-mode/routes/start.ts
@@ -0,0 +1,67 @@
+/**
+ * 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, branchName, maxConcurrency } = req.body as {
+ projectPath: string;
+ branchName?: string | null;
+ maxConcurrency?: number;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+ const worktreeDesc = normalizedBranchName
+ ? `worktree ${normalizedBranchName}`
+ : 'main worktree';
+
+ // Check if already running
+ if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
+ res.json({
+ success: true,
+ message: `Auto mode is already running for ${worktreeDesc}`,
+ alreadyRunning: true,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Start the auto loop for this project/worktree
+ const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
+ projectPath,
+ normalizedBranchName,
+ maxConcurrency
+ );
+
+ logger.info(
+ `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
+ );
+
+ res.json({
+ success: true,
+ message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
+ branchName: normalizedBranchName,
+ });
+ } 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..73c77945 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,41 @@ import { getErrorMessage, logError } from '../common.js';
export function createStatusHandler(autoModeService: AutoModeService) {
return async (req: Request, res: Response): Promise => {
try {
+ const { projectPath, branchName } = req.body as {
+ projectPath?: string;
+ branchName?: string | null;
+ };
+
+ // If projectPath is provided, return per-project/worktree status
+ if (projectPath) {
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+ const projectStatus = autoModeService.getStatusForProject(
+ projectPath,
+ normalizedBranchName
+ );
+ res.json({
+ success: true,
+ isRunning: projectStatus.runningCount > 0,
+ isAutoLoopRunning: projectStatus.isAutoLoopRunning,
+ runningFeatures: projectStatus.runningFeatures,
+ runningCount: projectStatus.runningCount,
+ maxConcurrency: projectStatus.maxConcurrency,
+ projectPath,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Fall back to global status for backward compatibility
const status = autoModeService.getStatus();
+ const activeProjects = autoModeService.getActiveAutoLoopProjects();
+ const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
res.json({
success: true,
...status,
+ activeAutoLoopProjects: activeProjects,
+ activeAutoLoopWorktrees: activeWorktrees,
});
} catch (error) {
logError(error, 'Get status failed');
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..b3c2fd52
--- /dev/null
+++ b/apps/server/src/routes/auto-mode/routes/stop.ts
@@ -0,0 +1,66 @@
+/**
+ * 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, branchName } = req.body as {
+ projectPath: string;
+ branchName?: string | null;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ // Normalize branchName: undefined becomes null
+ const normalizedBranchName = branchName ?? null;
+ const worktreeDesc = normalizedBranchName
+ ? `worktree ${normalizedBranchName}`
+ : 'main worktree';
+
+ // Check if running
+ if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
+ res.json({
+ success: true,
+ message: `Auto mode is not running for ${worktreeDesc}`,
+ wasRunning: false,
+ branchName: normalizedBranchName,
+ });
+ return;
+ }
+
+ // Stop the auto loop for this project/worktree
+ const runningCount = await autoModeService.stopAutoLoopForProject(
+ projectPath,
+ normalizedBranchName
+ );
+
+ logger.info(
+ `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
+ );
+
+ res.json({
+ success: true,
+ message: 'Auto mode stopped',
+ runningFeaturesCount: runningCount,
+ branchName: normalizedBranchName,
+ });
+ } catch (error) {
+ logError(error, 'Stop auto mode failed');
+ 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/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts
index e96ce8ea..0eac4b4c 100644
--- a/apps/server/src/routes/backlog-plan/generate-plan.ts
+++ b/apps/server/src/routes/backlog-plan/generate-plan.ts
@@ -25,7 +25,11 @@ import {
saveBacklogPlan,
} from './common.js';
import type { SettingsService } from '../../services/settings-service.js';
-import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+} from '../../lib/settings-helpers.js';
const featureLoader = new FeatureLoader();
@@ -117,18 +121,39 @@ export async function generateBacklogPlan(
content: 'Generating plan with AI...',
});
- // Get the model to use from settings or provided override
+ // Get the model to use from settings or provided override with provider info
let effectiveModel = model;
let thinkingLevel: ThinkingLevel | undefined;
- if (!effectiveModel) {
- const settings = await settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
- const resolved = resolvePhaseModel(phaseModelEntry);
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let credentials: import('@automaker/types').Credentials | undefined;
+
+ if (effectiveModel) {
+ // Use explicit override - just get credentials
+ credentials = await settingsService?.getCredentials();
+ } else if (settingsService) {
+ // Use settings-based model with provider info
+ const phaseResult = await getPhaseModelWithOverrides(
+ 'backlogPlanningModel',
+ settingsService,
+ projectPath,
+ '[BacklogPlan]'
+ );
+ const resolved = resolvePhaseModel(phaseResult.phaseModel);
+ effectiveModel = resolved.model;
+ thinkingLevel = resolved.thinkingLevel;
+ claudeCompatibleProvider = phaseResult.provider;
+ credentials = phaseResult.credentials;
+ } else {
+ // Fallback to defaults
+ const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel);
effectiveModel = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
- logger.info('[BacklogPlan] Using model:', effectiveModel);
+ logger.info(
+ '[BacklogPlan] Using model:',
+ effectiveModel,
+ claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
+ );
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers expect bare model IDs
@@ -173,6 +198,8 @@ ${userPrompt}`;
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
thinkingLevel, // Pass thinking level for extended thinking
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
let responseText = '';
diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts
index 9e0ae999..1a238d17 100644
--- a/apps/server/src/routes/backlog-plan/routes/apply.ts
+++ b/apps/server/src/routes/backlog-plan/routes/apply.ts
@@ -85,8 +85,9 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
- // Create the new feature
+ // Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, {
+ id: change.feature.id, // Use descriptive ID from AI if provided
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',
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/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts
index 5b1fc6ca..a59dfb74 100644
--- a/apps/server/src/routes/context/routes/describe-file.ts
+++ b/apps/server/src/routes/context/routes/describe-file.ts
@@ -12,7 +12,6 @@
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
-import { DEFAULT_PHASE_MODELS } from '@automaker/types';
import { PathNotAllowedError } from '@automaker/platform';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
@@ -22,6 +21,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
+ getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeFile');
@@ -155,15 +155,23 @@ ${contentToAnalyze}`;
'[DescribeFile]'
);
- // Get model from phase settings
- const settings = await settingsService?.getGlobalSettings();
- logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2));
- const phaseModelEntry =
- settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel;
- logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry));
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'fileDescriptionModel',
+ settingsService,
+ cwd,
+ '[DescribeFile]'
+ );
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
- logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
+ logger.info(
+ `Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
+ provider ? `via provider: ${provider.name}` : 'direct API'
+ );
// Use simpleQuery - provider abstraction handles routing to correct provider
const result = await simpleQuery({
@@ -175,6 +183,8 @@ ${contentToAnalyze}`;
thinkingLevel,
readOnly: true, // File description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const description = result.text;
diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts
index 70f9f7dc..018a932c 100644
--- a/apps/server/src/routes/context/routes/describe-image.ts
+++ b/apps/server/src/routes/context/routes/describe-image.ts
@@ -13,7 +13,7 @@
import type { Request, Response } from 'express';
import { createLogger, readImageAsBase64 } from '@automaker/utils';
-import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
+import { isCursorModel } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import * as secureFs from '../../../lib/secure-fs.js';
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
+ getPhaseModelWithOverrides,
} from '../../../lib/settings-helpers.js';
const logger = createLogger('DescribeImage');
@@ -273,13 +274,23 @@ export function createDescribeImageHandler(
'[DescribeImage]'
);
- // Get model from phase settings
- const settings = await settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'imageDescriptionModel',
+ settingsService,
+ cwd,
+ '[DescribeImage]'
+ );
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
- logger.info(`[${requestId}] Using model: ${model}`);
+ logger.info(
+ `[${requestId}] Using model: ${model}`,
+ provider ? `via provider: ${provider.name}` : 'direct API'
+ );
// Get customized prompts from settings
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
@@ -325,6 +336,8 @@ export function createDescribeImageHandler(
thinkingLevel,
readOnly: true, // Image description only reads, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts
index 5861b418..9045a18d 100644
--- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts
+++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts
@@ -12,7 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
-import { getPromptCustomization } from '../../../lib/settings-helpers.js';
+import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
import {
buildUserPrompt,
isValidEnhancementMode,
@@ -33,6 +33,8 @@ interface EnhanceRequestBody {
model?: string;
/** Optional thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
+ /** Optional project path for per-project Claude API profile */
+ projectPath?: string;
}
/**
@@ -62,7 +64,7 @@ export function createEnhanceHandler(
): (req: Request, res: Response) => Promise {
return async (req: Request, res: Response): Promise => {
try {
- const { originalText, enhancementMode, model, thinkingLevel } =
+ const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
req.body as EnhanceRequestBody;
// Validate required fields
@@ -121,8 +123,32 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
- // Resolve the model - use the passed model, default to sonnet for quality
- const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
+ // Check if the model is a provider model (like "GLM-4.5-Air")
+ // If so, get the provider config and resolved Claude model
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let providerResolvedModel: string | undefined;
+ let credentials = await settingsService?.getCredentials();
+
+ if (model && settingsService) {
+ const providerResult = await getProviderByModelId(
+ model,
+ settingsService,
+ '[EnhancePrompt]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ providerResolvedModel = providerResult.resolvedModel;
+ credentials = providerResult.credentials;
+ logger.info(
+ `Using provider "${providerResult.provider.name}" for model "${model}"` +
+ (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
+ );
+ }
+ }
+
+ // Resolve the model - use provider resolved model, passed model, or default to sonnet
+ const resolvedModel =
+ providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
logger.debug(`Using model: ${resolvedModel}`);
@@ -137,6 +163,8 @@ export function createEnhanceHandler(
allowedTools: [],
thinkingLevel,
readOnly: true, // Prompt enhancement only generates text, doesn't write files
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
});
const enhancedText = result.text;
diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts
index e7603eb8..4e5e0dcb 100644
--- a/apps/server/src/routes/features/routes/generate-title.ts
+++ b/apps/server/src/routes/features/routes/generate-title.ts
@@ -16,6 +16,7 @@ const logger = createLogger('GenerateTitle');
interface GenerateTitleRequestBody {
description: string;
+ projectPath?: string;
}
interface GenerateTitleSuccessResponse {
@@ -33,7 +34,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise {
return async (req: Request, res: Response): Promise => {
try {
- const { description } = req.body as GenerateTitleRequestBody;
+ const { description, projectPath } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {
@@ -60,6 +61,9 @@ export function createGenerateTitleHandler(
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
const systemPrompt = prompts.titleGeneration.systemPrompt;
+ // Get credentials for API calls (uses hardcoded haiku model, no phase setting)
+ const credentials = await settingsService?.getCredentials();
+
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
// Use simpleQuery - provider abstraction handles all the streaming/extraction
@@ -69,6 +73,7 @@ export function createGenerateTitleHandler(
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
const title = result.text;
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/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts
index e7d83d99..10465829 100644
--- a/apps/server/src/routes/github/routes/validate-issue.ts
+++ b/apps/server/src/routes/github/routes/validate-issue.ts
@@ -34,7 +34,11 @@ import {
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
-import { getPromptCustomization } from '../../../lib/settings-helpers.js';
+import {
+ getPromptCustomization,
+ getAutoLoadClaudeMdSetting,
+ getProviderByModelId,
+} from '../../../lib/settings-helpers.js';
import {
trySetValidationRunning,
clearValidationStatus,
@@ -43,7 +47,6 @@ import {
logger,
} from './validation-common.js';
import type { SettingsService } from '../../../services/settings-service.js';
-import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/**
* Request body for issue validation
@@ -164,12 +167,33 @@ ${basePrompt}`;
}
}
- logger.info(`Using model: ${model}`);
+ // Check if the model is a provider model (like "GLM-4.5-Air")
+ // If so, get the provider config and resolved Claude model
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let providerResolvedModel: string | undefined;
+ let credentials = await settingsService?.getCredentials();
+
+ if (settingsService) {
+ const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ providerResolvedModel = providerResult.resolvedModel;
+ credentials = providerResult.credentials;
+ logger.info(
+ `Using provider "${providerResult.provider.name}" for model "${model}"` +
+ (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
+ );
+ }
+ }
+
+ // Use provider resolved model if available, otherwise use original model
+ const effectiveModel = providerResolvedModel || (model as string);
+ logger.info(`Using model: ${effectiveModel}`);
// Use streamingQuery with event callbacks
const result = await streamingQuery({
prompt: finalPrompt,
- model: model as string,
+ model: effectiveModel,
cwd: projectPath,
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
abortController,
@@ -177,6 +201,8 @@ ${basePrompt}`;
reasoningEffort: effectiveReasoningEffort,
readOnly: true, // Issue validation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
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/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts
index 08a3628b..b828a4ab 100644
--- a/apps/server/src/routes/suggestions/generate-suggestions.ts
+++ b/apps/server/src/routes/suggestions/generate-suggestions.ts
@@ -15,7 +15,12 @@ import { FeatureLoader } from '../../services/feature-loader.js';
import { getAppSpecPath } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import type { SettingsService } from '../../services/settings-service.js';
-import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
+import {
+ getAutoLoadClaudeMdSetting,
+ getPromptCustomization,
+ getPhaseModelWithOverrides,
+ getProviderByModelId,
+} from '../../lib/settings-helpers.js';
const logger = createLogger('Suggestions');
@@ -167,11 +172,12 @@ ${prompts.suggestions.baseTemplate}`;
'[Suggestions]'
);
- // Get model from phase settings (AI Suggestions = suggestionsModel)
+ // Get model from phase settings with provider info (AI Suggestions = suggestionsModel)
// Use override if provided, otherwise fall back to settings
- const settings = await settingsService?.getGlobalSettings();
let model: string;
let thinkingLevel: ThinkingLevel | undefined;
+ let provider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let credentials: import('@automaker/types').Credentials | undefined;
if (modelOverride) {
// Use explicit override - resolve the model string
@@ -181,16 +187,47 @@ ${prompts.suggestions.baseTemplate}`;
});
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
+
+ // Try to find a provider for this model (e.g., GLM, MiniMax models)
+ if (settingsService) {
+ const providerResult = await getProviderByModelId(
+ modelOverride,
+ settingsService,
+ '[Suggestions]'
+ );
+ provider = providerResult.provider;
+ // Use resolved model from provider if available (maps to Claude model)
+ if (providerResult.resolvedModel) {
+ model = providerResult.resolvedModel;
+ }
+ credentials = providerResult.credentials ?? (await settingsService.getCredentials());
+ }
+ // If no settingsService, credentials remains undefined (initialized above)
+ } else if (settingsService) {
+ // Use settings-based model with provider info
+ const phaseResult = await getPhaseModelWithOverrides(
+ 'suggestionsModel',
+ settingsService,
+ projectPath,
+ '[Suggestions]'
+ );
+ const resolved = resolvePhaseModel(phaseResult.phaseModel);
+ model = resolved.model;
+ thinkingLevel = resolved.thinkingLevel;
+ provider = phaseResult.provider;
+ credentials = phaseResult.credentials;
} else {
- // Use settings-based model
- const phaseModelEntry =
- settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
- const resolved = resolvePhaseModel(phaseModelEntry);
+ // Fallback to defaults
+ const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel;
}
- logger.info('[Suggestions] Using model:', model);
+ logger.info(
+ '[Suggestions] Using model:',
+ model,
+ provider ? `via provider: ${provider.name}` : 'direct API'
+ );
let responseText = '';
@@ -223,6 +260,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
thinkingLevel,
readOnly: true, // Suggestions only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
+ claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
outputFormat: useStructuredOutput
? {
type: 'json_schema',
diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts
index 4b54ae9e..7459ca57 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';
@@ -41,6 +48,8 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
+import { createDiscardChangesHandler } from './routes/discard-changes.js';
+import { createListRemotesHandler } from './routes/list-remotes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -97,9 +106,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(
@@ -125,5 +150,21 @@ export function createWorktreeRoutes(
createRunInitScriptHandler(events)
);
+ // Discard changes route
+ router.post(
+ '/discard-changes',
+ validatePathParams('worktreePath'),
+ requireGitRepoOnly,
+ createDiscardChangesHandler()
+ );
+
+ // List remotes route
+ router.post(
+ '/list-remotes',
+ validatePathParams('worktreePath'),
+ requireValidWorktree,
+ createListRemotesHandler()
+ );
+
return router;
}
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/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts
index 75f43d7f..314fa8ce 100644
--- a/apps/server/src/routes/worktree/routes/diffs.ts
+++ b/apps/server/src/routes/worktree/routes/diffs.ts
@@ -39,7 +39,10 @@ export function createDiffsHandler() {
}
// Git worktrees are stored in project directory
- const worktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
// Check if worktree exists
diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts
new file mode 100644
index 00000000..4f15e053
--- /dev/null
+++ b/apps/server/src/routes/worktree/routes/discard-changes.ts
@@ -0,0 +1,112 @@
+/**
+ * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
+ *
+ * This performs a destructive operation that:
+ * 1. Resets staged changes (git reset HEAD)
+ * 2. Discards modified tracked files (git checkout .)
+ * 3. Removes untracked files and directories (git clean -fd)
+ *
+ * Note: Git repository validation (isGitRepo) is handled by
+ * the requireGitRepoOnly middleware in index.ts
+ */
+
+import type { Request, Response } from 'express';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { getErrorMessage, logError } from '../common.js';
+
+const execAsync = promisify(exec);
+
+export function createDiscardChangesHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { worktreePath } = req.body as {
+ worktreePath: string;
+ };
+
+ if (!worktreePath) {
+ res.status(400).json({
+ success: false,
+ error: 'worktreePath required',
+ });
+ return;
+ }
+
+ // Check for uncommitted changes first
+ const { stdout: status } = await execAsync('git status --porcelain', {
+ cwd: worktreePath,
+ });
+
+ if (!status.trim()) {
+ res.json({
+ success: true,
+ result: {
+ discarded: false,
+ message: 'No changes to discard',
+ },
+ });
+ return;
+ }
+
+ // Count the files that will be affected
+ const lines = status.trim().split('\n').filter(Boolean);
+ const fileCount = lines.length;
+
+ // Get branch name before discarding
+ const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
+ cwd: worktreePath,
+ });
+ const branchName = branchOutput.trim();
+
+ // Discard all changes:
+ // 1. Reset any staged changes
+ await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
+ // Ignore errors - might fail if there's nothing staged
+ });
+
+ // 2. Discard changes in tracked files
+ await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
+ // Ignore errors - might fail if there are no tracked changes
+ });
+
+ // 3. Remove untracked files and directories
+ await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
+ // Ignore errors - might fail if there are no untracked files
+ });
+
+ // Verify all changes were discarded
+ const { stdout: finalStatus } = await execAsync('git status --porcelain', {
+ cwd: worktreePath,
+ });
+
+ if (finalStatus.trim()) {
+ // Some changes couldn't be discarded (possibly ignored files or permission issues)
+ const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
+ res.json({
+ success: true,
+ result: {
+ discarded: true,
+ filesDiscarded: fileCount - remainingCount,
+ filesRemaining: remainingCount,
+ branch: branchName,
+ message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
+ },
+ });
+ } else {
+ res.json({
+ success: true,
+ result: {
+ discarded: true,
+ filesDiscarded: fileCount,
+ filesRemaining: 0,
+ branch: branchName,
+ message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
+ },
+ });
+ }
+ } catch (error) {
+ logError(error, 'Discard changes failed');
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts
index 4d29eb26..f3d4ed1a 100644
--- a/apps/server/src/routes/worktree/routes/file-diff.ts
+++ b/apps/server/src/routes/worktree/routes/file-diff.ts
@@ -37,7 +37,10 @@ export function createFileDiffHandler() {
}
// Git worktrees are stored in project directory
- const worktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);
diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts
index a450659f..d0444ad0 100644
--- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts
+++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts
@@ -10,14 +10,14 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
-import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
-import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
+import { isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
+import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec);
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
error: string;
}
-async function extractTextFromStream(
- stream: AsyncIterable<{
- type: string;
- subtype?: string;
- result?: string;
- message?: {
- content?: Array<{ type: string; text?: string }>;
- };
- }>
-): Promise {
- let responseText = '';
-
- for await (const msg of stream) {
- if (msg.type === 'assistant' && msg.message?.content) {
- for (const block of msg.message.content) {
- if (block.type === 'text' && block.text) {
- responseText += block.text;
- }
- }
- } else if (msg.type === 'result' && msg.subtype === 'success') {
- responseText = msg.result || responseText;
- }
- }
-
- return responseText;
-}
-
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise {
@@ -184,68 +157,69 @@ export function createGenerateCommitMessageHandler(
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
- // Get model from phase settings
- const settings = await settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
- const { model } = resolvePhaseModel(phaseModelEntry);
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider: claudeCompatibleProvider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'commitMessageModel',
+ settingsService,
+ worktreePath,
+ '[GenerateCommitMessage]'
+ );
+ const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
- logger.info(`Using model for commit message: ${model}`);
+ logger.info(
+ `Using model for commit message: ${model}`,
+ claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
+ );
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
- let message: string;
+ // Get provider for the model type
+ const aiProvider = ProviderFactory.getProviderForModel(model);
+ const bareModel = stripProviderPrefix(model);
- // Route to appropriate provider based on model type
- if (isCursorModel(model)) {
- // Use Cursor provider for Cursor models
- logger.info(`Using Cursor provider for model: ${model}`);
+ // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
+ const effectivePrompt = isCursorModel(model)
+ ? `${systemPrompt}\n\n${userPrompt}`
+ : userPrompt;
+ const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
- const provider = ProviderFactory.getProviderForModel(model);
- const bareModel = stripProviderPrefix(model);
+ logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
- const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
+ let responseText = '';
+ const stream = aiProvider.executeQuery({
+ prompt: effectivePrompt,
+ model: bareModel,
+ cwd: worktreePath,
+ systemPrompt: effectiveSystemPrompt,
+ maxTurns: 1,
+ allowedTools: [],
+ readOnly: true,
+ thinkingLevel, // Pass thinking level for extended thinking support
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ });
- let responseText = '';
- const cursorStream = provider.executeQuery({
- prompt: cursorPrompt,
- model: bareModel,
- cwd: worktreePath,
- maxTurns: 1,
- allowedTools: [],
- readOnly: true,
- });
-
- // Wrap with timeout to prevent indefinite hangs
- for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
- if (msg.type === 'assistant' && msg.message?.content) {
- for (const block of msg.message.content) {
- if (block.type === 'text' && block.text) {
- responseText += block.text;
- }
+ // Wrap with timeout to prevent indefinite hangs
+ for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
+ if (msg.type === 'assistant' && msg.message?.content) {
+ for (const block of msg.message.content) {
+ if (block.type === 'text' && block.text) {
+ responseText += block.text;
}
}
+ } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
+ // Use result if available (some providers return final text here)
+ responseText = msg.result;
}
-
- message = responseText.trim();
- } else {
- // Use Claude SDK for Claude models
- const stream = query({
- prompt: userPrompt,
- options: {
- model,
- systemPrompt,
- maxTurns: 1,
- allowedTools: [],
- permissionMode: 'default',
- },
- });
-
- // Wrap with timeout to prevent indefinite hangs
- message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
}
+ const message = responseText.trim();
+
if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = {
diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts
index 3d512452..5c2eb808 100644
--- a/apps/server/src/routes/worktree/routes/info.ts
+++ b/apps/server/src/routes/worktree/routes/info.ts
@@ -28,7 +28,10 @@ export function createInfoHandler() {
}
// Check if worktree exists (git worktrees are stored in project directory)
- const worktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts
index c6db10fc..6c999552 100644
--- a/apps/server/src/routes/worktree/routes/list-branches.ts
+++ b/apps/server/src/routes/worktree/routes/list-branches.ts
@@ -110,9 +110,10 @@ export function createListBranchesHandler() {
}
}
- // Get ahead/behind count for current branch
+ // Get ahead/behind count for current branch and check if remote branch exists
let aheadCount = 0;
let behindCount = 0;
+ let hasRemoteBranch = false;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync(
@@ -121,6 +122,7 @@ export function createListBranchesHandler() {
);
if (upstreamOutput.trim()) {
+ hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
{ cwd: worktreePath }
@@ -130,7 +132,18 @@ export function createListBranchesHandler() {
behindCount = behind || 0;
}
} catch {
- // No upstream branch set, that's okay
+ // No upstream branch set - check if the branch exists on any remote
+ try {
+ // Check if there's a matching branch on origin (most common remote)
+ const { stdout: remoteBranchOutput } = await execAsync(
+ `git ls-remote --heads origin ${currentBranch}`,
+ { cwd: worktreePath, timeout: 5000 }
+ );
+ hasRemoteBranch = remoteBranchOutput.trim().length > 0;
+ } catch {
+ // No remote branch found or origin doesn't exist
+ hasRemoteBranch = false;
+ }
}
res.json({
@@ -140,6 +153,7 @@ export function createListBranchesHandler() {
branches,
aheadCount,
behindCount,
+ hasRemoteBranch,
},
});
} catch (error) {
diff --git a/apps/server/src/routes/worktree/routes/list-remotes.ts b/apps/server/src/routes/worktree/routes/list-remotes.ts
new file mode 100644
index 00000000..1180afce
--- /dev/null
+++ b/apps/server/src/routes/worktree/routes/list-remotes.ts
@@ -0,0 +1,127 @@
+/**
+ * POST /list-remotes endpoint - List all remotes and their branches
+ *
+ * Note: Git repository validation (isGitRepo, hasCommits) is handled by
+ * the requireValidWorktree middleware in index.ts
+ */
+
+import type { Request, Response } from 'express';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { getErrorMessage, logWorktreeError } from '../common.js';
+
+const execAsync = promisify(exec);
+
+interface RemoteBranch {
+ name: string;
+ fullRef: string;
+}
+
+interface RemoteInfo {
+ name: string;
+ url: string;
+ branches: RemoteBranch[];
+}
+
+export function createListRemotesHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { worktreePath } = req.body as {
+ worktreePath: string;
+ };
+
+ if (!worktreePath) {
+ res.status(400).json({
+ success: false,
+ error: 'worktreePath required',
+ });
+ return;
+ }
+
+ // Get list of remotes
+ const { stdout: remotesOutput } = await execAsync('git remote -v', {
+ cwd: worktreePath,
+ });
+
+ // Parse remotes (each remote appears twice - once for fetch, once for push)
+ const remotesSet = new Map();
+ remotesOutput
+ .trim()
+ .split('\n')
+ .filter((line) => line.trim())
+ .forEach((line) => {
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
+ if (match) {
+ remotesSet.set(match[1], match[2]);
+ }
+ });
+
+ // Fetch latest from all remotes (silently, don't fail if offline)
+ try {
+ await execAsync('git fetch --all --quiet', {
+ cwd: worktreePath,
+ timeout: 15000, // 15 second timeout
+ });
+ } catch {
+ // Ignore fetch errors - we'll use cached remote refs
+ }
+
+ // Get all remote branches
+ const { stdout: remoteBranchesOutput } = await execAsync(
+ 'git branch -r --format="%(refname:short)"',
+ { cwd: worktreePath }
+ );
+
+ // Group branches by remote
+ const remotesBranches = new Map();
+ remotesSet.forEach((_, remoteName) => {
+ remotesBranches.set(remoteName, []);
+ });
+
+ remoteBranchesOutput
+ .trim()
+ .split('\n')
+ .filter((line) => line.trim())
+ .forEach((line) => {
+ const cleanLine = line.trim().replace(/^['"]|['"]$/g, '');
+ // Skip HEAD pointers like "origin/HEAD"
+ if (cleanLine.includes('/HEAD')) return;
+
+ // Parse remote name from branch ref (e.g., "origin/main" -> "origin")
+ const slashIndex = cleanLine.indexOf('/');
+ if (slashIndex === -1) return;
+
+ const remoteName = cleanLine.substring(0, slashIndex);
+ const branchName = cleanLine.substring(slashIndex + 1);
+
+ if (remotesBranches.has(remoteName)) {
+ remotesBranches.get(remoteName)!.push({
+ name: branchName,
+ fullRef: cleanLine,
+ });
+ }
+ });
+
+ // Build final result
+ const remotes: RemoteInfo[] = [];
+ remotesSet.forEach((url, name) => {
+ remotes.push({
+ name,
+ url,
+ branches: remotesBranches.get(name) || [],
+ });
+ });
+
+ res.json({
+ success: true,
+ result: {
+ remotes,
+ },
+ });
+ } catch (error) {
+ const worktreePath = req.body?.worktreePath;
+ logWorktreeError(error, 'List remotes failed', worktreePath);
+ res.status(500).json({ success: false, error: getErrorMessage(error) });
+ }
+ };
+}
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/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts
index 69f120b8..48df7893 100644
--- a/apps/server/src/routes/worktree/routes/merge.ts
+++ b/apps/server/src/routes/worktree/routes/merge.ts
@@ -1,5 +1,7 @@
/**
- * POST /merge endpoint - Merge feature (merge worktree branch into main)
+ * POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
+ *
+ * Allows merging a worktree branch into any target branch (defaults to 'main').
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidProject middleware in index.ts
@@ -8,18 +10,21 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
-import { getErrorMessage, logError } from '../common.js';
+import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
+import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
+const logger = createLogger('Worktree');
export function createMergeHandler() {
return async (req: Request, res: Response): Promise => {
try {
- const { projectPath, branchName, worktreePath, options } = req.body as {
+ const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
projectPath: string;
branchName: string;
worktreePath: string;
- options?: { squash?: boolean; message?: string };
+ targetBranch?: string; // Branch to merge into (defaults to 'main')
+ options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
};
if (!projectPath || !branchName || !worktreePath) {
@@ -30,7 +35,10 @@ export function createMergeHandler() {
return;
}
- // Validate branch exists
+ // Determine the target branch (default to 'main')
+ const mergeTo = targetBranch || 'main';
+
+ // Validate source branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
@@ -41,12 +49,44 @@ export function createMergeHandler() {
return;
}
- // Merge the feature branch
+ // Validate target branch exists
+ try {
+ await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
+ } catch {
+ res.status(400).json({
+ success: false,
+ error: `Target branch "${mergeTo}" does not exist`,
+ });
+ return;
+ }
+
+ // Merge the feature branch into the target branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
- : `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
+ : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
- await execAsync(mergeCmd, { cwd: projectPath });
+ try {
+ await execAsync(mergeCmd, { cwd: projectPath });
+ } catch (mergeError: unknown) {
+ // Check if this is a merge conflict
+ const err = mergeError as { stdout?: string; stderr?: string; message?: string };
+ const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
+ const hasConflicts =
+ output.includes('CONFLICT') || output.includes('Automatic merge failed');
+
+ if (hasConflicts) {
+ // Return conflict-specific error message that frontend can detect
+ res.status(409).json({
+ success: false,
+ error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
+ hasConflicts: true,
+ });
+ return;
+ }
+
+ // Re-throw non-conflict errors to be handled by outer catch
+ throw mergeError;
+ }
// If squash merge, need to commit
if (options?.squash) {
@@ -55,17 +95,46 @@ export function createMergeHandler() {
});
}
- // Clean up worktree and branch
- try {
- await execAsync(`git worktree remove "${worktreePath}" --force`, {
- cwd: projectPath,
- });
- await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
- } catch {
- // Cleanup errors are non-fatal
+ // Optionally delete the worktree and branch after merging
+ let worktreeDeleted = false;
+ let branchDeleted = false;
+
+ if (options?.deleteWorktreeAndBranch) {
+ // Remove the worktree
+ try {
+ await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
+ worktreeDeleted = true;
+ } catch {
+ // Try with prune if remove fails
+ try {
+ await execGitCommand(['worktree', 'prune'], projectPath);
+ worktreeDeleted = true;
+ } catch {
+ logger.warn(`Failed to remove worktree: ${worktreePath}`);
+ }
+ }
+
+ // Delete the branch (but not main/master)
+ if (branchName !== 'main' && branchName !== 'master') {
+ if (!isValidBranchName(branchName)) {
+ logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
+ } else {
+ try {
+ await execGitCommand(['branch', '-D', branchName], projectPath);
+ branchDeleted = true;
+ } catch {
+ logger.warn(`Failed to delete branch: ${branchName}`);
+ }
+ }
+ }
}
- res.json({ success: true, mergedBranch: branchName });
+ res.json({
+ success: true,
+ mergedBranch: branchName,
+ targetBranch: mergeTo,
+ deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
+ });
} catch (error) {
logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
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/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts
index b044ba00..0e082b3f 100644
--- a/apps/server/src/routes/worktree/routes/push.ts
+++ b/apps/server/src/routes/worktree/routes/push.ts
@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
export function createPushHandler() {
return async (req: Request, res: Response): Promise => {
try {
- const { worktreePath, force } = req.body as {
+ const { worktreePath, force, remote } = req.body as {
worktreePath: string;
force?: boolean;
+ remote?: string;
};
if (!worktreePath) {
@@ -34,15 +35,18 @@ export function createPushHandler() {
});
const branchName = branchOutput.trim();
+ // Use specified remote or default to 'origin'
+ const targetRemote = remote || 'origin';
+
// Push the branch
const forceFlag = force ? '--force' : '';
try {
- await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
+ await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
} catch {
// Try setting upstream
- await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
+ await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
}
@@ -52,7 +56,7 @@ export function createPushHandler() {
result: {
branch: branchName,
pushed: true,
- message: `Successfully pushed ${branchName} to origin`,
+ message: `Successfully pushed ${branchName} to ${targetRemote}`,
},
});
} catch (error) {
diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts
index f9d6bf88..b44c5ae4 100644
--- a/apps/server/src/routes/worktree/routes/status.ts
+++ b/apps/server/src/routes/worktree/routes/status.ts
@@ -28,7 +28,10 @@ export function createStatusHandler() {
}
// Git worktrees are stored in project directory
- const worktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(worktreePath);
diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts
index 359719d3..09c91979 100644
--- a/apps/server/src/services/agent-service.ts
+++ b/apps/server/src/services/agent-service.ts
@@ -29,6 +29,7 @@ import {
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
+ getProviderByModelId,
} from '../lib/settings-helpers.js';
interface Message {
@@ -274,6 +275,30 @@ export class AgentService {
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
+ // Get credentials for API calls
+ const credentials = await this.settingsService?.getCredentials();
+
+ // Try to find a provider for the model (if it's a provider model like "GLM-4.7")
+ // This allows users to select provider models in the Agent Runner UI
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let providerResolvedModel: string | undefined;
+ const requestedModel = model || session.model;
+ if (requestedModel && this.settingsService) {
+ const providerResult = await getProviderByModelId(
+ requestedModel,
+ this.settingsService,
+ '[AgentService]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ providerResolvedModel = providerResult.resolvedModel;
+ this.logger.info(
+ `[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` +
+ (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
+ );
+ }
+ }
+
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
@@ -299,10 +324,16 @@ export class AgentService {
// Use thinking level and reasoning effort from request, or fall back to session's stored values
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
+
+ // When using a provider model, use the resolved Claude model (from mapsToClaudeModel)
+ // e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"
+ const modelForSdk = providerResolvedModel || model;
+ const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
+
const sdkOptions = createChatOptions({
cwd: effectiveWorkDir,
- model: model,
- sessionModel: session.model,
+ model: modelForSdk,
+ sessionModel: sessionModelForSdk,
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
@@ -378,6 +409,8 @@ export class AgentService {
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
};
// Build prompt content with images
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index 454b7ec0..9468f2b4 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -21,7 +21,12 @@ import type {
ThinkingLevel,
PlanningMode,
} from '@automaker/types';
-import { DEFAULT_PHASE_MODELS, isClaudeModel, stripProviderPrefix } from '@automaker/types';
+import {
+ DEFAULT_PHASE_MODELS,
+ DEFAULT_MAX_CONCURRENCY,
+ isClaudeModel,
+ stripProviderPrefix,
+} from '@automaker/types';
import {
buildPromptWithImages,
classifyError,
@@ -63,11 +68,28 @@ import {
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
+ getProviderByModelId,
+ getPhaseModelWithOverrides,
} from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec);
+/**
+ * Get the current branch name for a git repository
+ * @param projectPath - Path to the git repository
+ * @returns The current branch name, or null if not in a git repo or on detached HEAD
+ */
+async function getCurrentBranch(projectPath: string): Promise {
+ try {
+ const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
+ const branch = stdout.trim();
+ return branch || null;
+ } catch {
+ return null;
+ }
+}
+
// PlanningMode type is imported from @automaker/types
interface ParsedTask {
@@ -233,6 +255,30 @@ interface AutoModeConfig {
maxConcurrency: number;
useWorktrees: boolean;
projectPath: string;
+ branchName: string | null; // null = main worktree
+}
+
+/**
+ * Generate a unique key for worktree-scoped auto loop state
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ */
+function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
+ const normalizedBranch = branchName === 'main' ? null : branchName;
+ return `${projectPath}::${normalizedBranch ?? '__main__'}`;
+}
+
+/**
+ * Per-worktree autoloop state for multi-project/worktree support
+ */
+interface ProjectAutoLoopState {
+ abortController: AbortController;
+ config: AutoModeConfig;
+ isRunning: boolean;
+ consecutiveFailures: { timestamp: number; error: string }[];
+ pausedDueToFailures: boolean;
+ hasEmittedIdleEvent: boolean;
+ branchName: string | null; // null = main worktree
}
/**
@@ -244,6 +290,7 @@ interface ExecutionState {
autoLoopWasRunning: boolean;
maxConcurrency: number;
projectPath: string;
+ branchName: string | null; // null = main worktree
runningFeatureIds: string[];
savedAt: string;
}
@@ -252,8 +299,9 @@ interface ExecutionState {
const DEFAULT_EXECUTION_STATE: ExecutionState = {
version: 1,
autoLoopWasRunning: false,
- maxConcurrency: 3,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
projectPath: '',
+ branchName: null,
runningFeatureIds: [],
savedAt: '',
};
@@ -267,14 +315,19 @@ 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;
+ // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject)
+ private hasEmittedIdleEvent = false;
constructor(events: EventEmitter, settingsService?: SettingsService) {
this.events = events;
@@ -284,6 +337,44 @@ export class AutoModeService {
/**
* Track a failure and check if we should pause due to consecutive failures.
* This handles cases where the SDK doesn't return useful error messages.
+ * @param projectPath - The project to track failure for
+ * @param errorInfo - Error information
+ */
+ private trackFailureAndCheckPauseForProject(
+ projectPath: string,
+ errorInfo: { type: string; message: string }
+ ): boolean {
+ const projectState = this.autoLoopsByProject.get(projectPath);
+ if (!projectState) {
+ // Fall back to legacy global tracking
+ return this.trackFailureAndCheckPause(errorInfo);
+ }
+
+ const now = Date.now();
+
+ // Add this failure
+ projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
+
+ // Remove old failures outside the window
+ projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
+ (f) => now - f.timestamp < FAILURE_WINDOW_MS
+ );
+
+ // Check if we've hit the threshold
+ if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
+ return true; // Should pause
+ }
+
+ // Also immediately pause for known quota/rate limit errors
+ if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Track a failure and check if we should pause due to consecutive failures (legacy global).
*/
private trackFailureAndCheckPause(errorInfo: { type: string; message: string }): boolean {
const now = Date.now();
@@ -311,7 +402,49 @@ export class AutoModeService {
/**
* Signal that we should pause due to repeated failures or quota exhaustion.
- * This will pause the auto loop to prevent repeated failures.
+ * This will pause the auto loop for a specific project.
+ * @param projectPath - The project to pause
+ * @param errorInfo - Error information
+ */
+ private signalShouldPauseForProject(
+ projectPath: string,
+ errorInfo: { type: string; message: string }
+ ): void {
+ const projectState = this.autoLoopsByProject.get(projectPath);
+ if (!projectState) {
+ // Fall back to legacy global pause
+ this.signalShouldPause(errorInfo);
+ return;
+ }
+
+ if (projectState.pausedDueToFailures) {
+ return; // Already paused
+ }
+
+ projectState.pausedDueToFailures = true;
+ const failureCount = projectState.consecutiveFailures.length;
+ logger.info(
+ `Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
+ );
+
+ // Emit event to notify UI
+ this.emitAutoModeEvent('auto_mode_paused_failures', {
+ message:
+ failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
+ ? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
+ : 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
+ errorType: errorInfo.type,
+ originalError: errorInfo.message,
+ failureCount,
+ projectPath,
+ });
+
+ // Stop the auto loop for this project
+ this.stopAutoLoopForProject(projectPath);
+ }
+
+ /**
+ * Signal that we should pause due to repeated failures or quota exhaustion (legacy global).
*/
private signalShouldPause(errorInfo: { type: string; message: string }): void {
if (this.pausedDueToFailures) {
@@ -341,7 +474,19 @@ export class AutoModeService {
}
/**
- * Reset failure tracking (called when user manually restarts auto mode)
+ * Reset failure tracking for a specific project
+ * @param projectPath - The project to reset failure tracking for
+ */
+ private resetFailureTrackingForProject(projectPath: string): void {
+ const projectState = this.autoLoopsByProject.get(projectPath);
+ if (projectState) {
+ projectState.consecutiveFailures = [];
+ projectState.pausedDueToFailures = false;
+ }
+ }
+
+ /**
+ * Reset failure tracking (called when user manually restarts auto mode) - legacy global
*/
private resetFailureTracking(): void {
this.consecutiveFailures = [];
@@ -349,16 +494,398 @@ 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 = [];
}
+ private async resolveMaxConcurrency(
+ projectPath: string,
+ branchName: string | null,
+ provided?: number
+ ): Promise {
+ if (typeof provided === 'number' && Number.isFinite(provided)) {
+ return provided;
+ }
+
+ if (!this.settingsService) {
+ return DEFAULT_MAX_CONCURRENCY;
+ }
+
+ try {
+ const settings = await this.settingsService.getGlobalSettings();
+ const globalMax =
+ typeof settings.maxConcurrency === 'number'
+ ? settings.maxConcurrency
+ : DEFAULT_MAX_CONCURRENCY;
+ const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
+ const autoModeByWorktree = settings.autoModeByWorktree;
+
+ if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
+ const key = `${projectId}::${branchName ?? '__main__'}`;
+ const entry = autoModeByWorktree[key];
+ if (entry && typeof entry.maxConcurrency === 'number') {
+ return entry.maxConcurrency;
+ }
+ }
+
+ return globalMax;
+ } catch {
+ return DEFAULT_MAX_CONCURRENCY;
+ }
+ }
+
+ /**
+ * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees)
+ * @param projectPath - The project to start auto mode for
+ * @param branchName - The branch name for worktree scoping, null for main worktree
+ * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY)
+ */
+ async startAutoLoopForProject(
+ projectPath: string,
+ branchName: string | null = null,
+ maxConcurrency?: number
+ ): Promise {
+ const resolvedMaxConcurrency = await this.resolveMaxConcurrency(
+ projectPath,
+ branchName,
+ maxConcurrency
+ );
+
+ // Use worktree-scoped key
+ const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
+
+ // Check if this project/worktree already has an active autoloop
+ const existingState = this.autoLoopsByProject.get(worktreeKey);
+ if (existingState?.isRunning) {
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ throw new Error(
+ `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}`
+ );
+ }
+
+ // Create new project/worktree autoloop state
+ const abortController = new AbortController();
+ const config: AutoModeConfig = {
+ maxConcurrency: resolvedMaxConcurrency,
+ useWorktrees: true,
+ projectPath,
+ branchName,
+ };
+
+ const projectState: ProjectAutoLoopState = {
+ abortController,
+ config,
+ isRunning: true,
+ consecutiveFailures: [],
+ pausedDueToFailures: false,
+ hasEmittedIdleEvent: false,
+ branchName,
+ };
+
+ this.autoLoopsByProject.set(worktreeKey, projectState);
+
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(
+ `Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
+ );
+
+ this.emitAutoModeEvent('auto_mode_started', {
+ message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
+ projectPath,
+ branchName,
+ maxConcurrency: resolvedMaxConcurrency,
+ });
+
+ // Save execution state for recovery after restart
+ await this.saveExecutionStateForProject(projectPath, branchName, resolvedMaxConcurrency);
+
+ // Run the loop in the background
+ this.runAutoLoopForProject(worktreeKey).catch((error) => {
+ const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error);
+ const errorInfo = classifyError(error);
+ this.emitAutoModeEvent('auto_mode_error', {
+ error: errorInfo.message,
+ errorType: errorInfo.type,
+ projectPath,
+ branchName,
+ });
+ });
+
+ return resolvedMaxConcurrency;
+ }
+
+ /**
+ * Run the auto loop for a specific project/worktree
+ * @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__)
+ */
+ private async runAutoLoopForProject(worktreeKey: string): Promise {
+ const projectState = this.autoLoopsByProject.get(worktreeKey);
+ if (!projectState) {
+ logger.warn(`No project state found for ${worktreeKey}, stopping loop`);
+ return;
+ }
+
+ const { projectPath, branchName } = projectState.config;
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+
+ logger.info(
+ `[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
+ );
+ let iterationCount = 0;
+
+ while (projectState.isRunning && !projectState.abortController.signal.aborted) {
+ iterationCount++;
+ try {
+ // Count running features for THIS project/worktree only
+ const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
+
+ // Check if we have capacity for this project/worktree
+ 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/worktree
+ const pendingFeatures = await this.loadPendingFeatures(projectPath, branchName);
+
+ logger.info(
+ `[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}`
+ );
+
+ if (pendingFeatures.length === 0) {
+ // Emit idle event only once when backlog is empty AND no features are running
+ if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) {
+ this.emitAutoModeEvent('auto_mode_idle', {
+ message: 'No pending features - auto mode idle',
+ projectPath,
+ branchName,
+ });
+ projectState.hasEmittedIdleEvent = true;
+ logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`);
+ } else if (projectRunningCount > 0) {
+ logger.info(
+ `[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...`
+ );
+ } else {
+ logger.warn(
+ `[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.`
+ );
+ }
+ await this.sleep(10000);
+ continue;
+ }
+
+ // Find a feature not currently running and not yet finished
+ const nextFeature = pendingFeatures.find(
+ (f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f)
+ );
+
+ if (nextFeature) {
+ logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
+ // Reset idle event flag since we're doing work again
+ projectState.hasEmittedIdleEvent = false;
+ // 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;
+ }
+
+ /**
+ * Get count of running features for a specific worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
+ */
+ private async getRunningCountForWorktree(
+ projectPath: string,
+ branchName: string | null
+ ): Promise {
+ // Get the actual primary branch name for the project
+ const primaryBranch = await getCurrentBranch(projectPath);
+
+ let count = 0;
+ for (const [, feature] of this.runningFeatures) {
+ // Filter by project path AND branchName to get accurate worktree-specific count
+ const featureBranch = feature.branchName ?? null;
+ if (branchName === null) {
+ // Main worktree: match features with branchName === null OR branchName matching primary branch
+ const isPrimaryBranch =
+ featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
+ if (feature.projectPath === projectPath && isPrimaryBranch) {
+ count++;
+ }
+ } else {
+ // Feature worktree: exact match
+ if (feature.projectPath === projectPath && featureBranch === branchName) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Stop the auto mode loop for a specific project/worktree
+ * @param projectPath - The project to stop auto mode for
+ * @param branchName - The branch name, or null for main worktree
+ */
+ async stopAutoLoopForProject(
+ projectPath: string,
+ branchName: string | null = null
+ ): Promise {
+ const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
+ const projectState = this.autoLoopsByProject.get(worktreeKey);
+ if (!projectState) {
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.warn(`No auto loop running for ${worktreeDesc} in 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, branchName);
+
+ // Emit stop event
+ if (wasRunning) {
+ this.emitAutoModeEvent('auto_mode_stopped', {
+ message: 'Auto mode stopped',
+ projectPath,
+ branchName,
+ });
+ }
+
+ // Remove from map
+ this.autoLoopsByProject.delete(worktreeKey);
+
+ return await this.getRunningCountForWorktree(projectPath, branchName);
+ }
+
+ /**
+ * Check if auto mode is running for a specific project/worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ */
+ isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
+ const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
+ const projectState = this.autoLoopsByProject.get(worktreeKey);
+ return projectState?.isRunning ?? false;
+ }
+
+ /**
+ * Get auto loop config for a specific project/worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ */
+ getAutoLoopConfigForProject(
+ projectPath: string,
+ branchName: string | null = null
+ ): AutoModeConfig | null {
+ const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
+ const projectState = this.autoLoopsByProject.get(worktreeKey);
+ return projectState?.config ?? null;
+ }
+
+ /**
+ * Save execution state for a specific project/worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ * @param maxConcurrency - Maximum concurrent features
+ */
+ private async saveExecutionStateForProject(
+ projectPath: string,
+ branchName: string | null,
+ 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,
+ branchName,
+ runningFeatureIds,
+ savedAt: new Date().toISOString(),
+ };
+ await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(
+ `Saved execution state for ${worktreeDesc} in ${projectPath}: ${runningFeatureIds.length} running features`
+ );
+ } catch (error) {
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.error(`Failed to save execution state for ${worktreeDesc} in ${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 {
+ async startAutoLoop(
+ projectPath: string,
+ maxConcurrency = DEFAULT_MAX_CONCURRENCY
+ ): 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');
}
@@ -372,6 +899,7 @@ export class AutoModeService {
maxConcurrency,
useWorktrees: true,
projectPath,
+ branchName: null,
};
this.emitAutoModeEvent('auto_mode_started', {
@@ -396,6 +924,9 @@ export class AutoModeService {
});
}
+ /**
+ * @deprecated Use runAutoLoopForProject instead
+ */
private async runAutoLoop(): Promise {
while (
this.autoLoopRunning &&
@@ -404,7 +935,7 @@ export class AutoModeService {
) {
try {
// Check if we have capacity
- if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
+ if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) {
await this.sleep(5000);
continue;
}
@@ -413,10 +944,22 @@ export class AutoModeService {
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
if (pendingFeatures.length === 0) {
- this.emitAutoModeEvent('auto_mode_idle', {
- message: 'No pending features - auto mode idle',
- projectPath: this.config!.projectPath,
- });
+ // Emit idle event only once when backlog is empty AND no features are running
+ const runningCount = this.runningFeatures.size;
+ if (runningCount === 0 && !this.hasEmittedIdleEvent) {
+ this.emitAutoModeEvent('auto_mode_idle', {
+ message: 'No pending features - auto mode idle',
+ projectPath: this.config!.projectPath,
+ });
+ this.hasEmittedIdleEvent = true;
+ logger.info(`[AutoLoop] Backlog complete, auto mode now idle`);
+ } else if (runningCount > 0) {
+ logger.debug(
+ `[AutoLoop] No pending features, ${runningCount} still running, waiting...`
+ );
+ } else {
+ logger.debug(`[AutoLoop] No pending features, waiting for new items...`);
+ }
await this.sleep(10000);
continue;
}
@@ -425,6 +968,8 @@ export class AutoModeService {
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
if (nextFeature) {
+ // Reset idle event flag since we're doing work again
+ this.hasEmittedIdleEvent = false;
// Start feature execution in background
this.executeFeature(
this.config!.projectPath,
@@ -448,6 +993,7 @@ export class AutoModeService {
/**
* Stop the auto mode loop
+ * @deprecated Use stopAutoLoopForProject instead for multi-project support
*/
async stopAutoLoop(): Promise {
const wasRunning = this.autoLoopRunning;
@@ -474,6 +1020,41 @@ export class AutoModeService {
return this.runningFeatures.size;
}
+ /**
+ * Check if there's capacity to start a feature on a worktree.
+ * This respects per-worktree agent limits from autoModeByWorktree settings.
+ *
+ * @param projectPath - The main project path
+ * @param featureId - The feature ID to check capacity for
+ * @returns Object with hasCapacity boolean and details about current/max agents
+ */
+ async checkWorktreeCapacity(
+ projectPath: string,
+ featureId: string
+ ): Promise<{
+ hasCapacity: boolean;
+ currentAgents: number;
+ maxAgents: number;
+ branchName: string | null;
+ }> {
+ // Load feature to get branchName
+ const feature = await this.loadFeature(projectPath, featureId);
+ const branchName = feature?.branchName ?? null;
+
+ // Get per-worktree limit
+ const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
+
+ // Get current running count for this worktree
+ const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
+
+ return {
+ hasCapacity: currentAgents < maxAgents,
+ currentAgents,
+ maxAgents,
+ branchName,
+ };
+ }
+
/**
* Execute a single feature
* @param projectPath - The main project path
@@ -512,14 +1093,51 @@ export class AutoModeService {
if (isAutoMode) {
await this.saveExecutionState(projectPath);
}
+ // Declare feature outside try block so it's available in catch for error reporting
+ let feature: Awaited> | null = null;
try {
// Validate that project path is allowed using centralized validation
validateWorkingDirectory(projectPath);
+ // Load feature details FIRST to get status and plan info
+ feature = await this.loadFeature(projectPath, featureId);
+ if (!feature) {
+ throw new Error(`Feature ${featureId} not found`);
+ }
+
// Check if feature has existing context - if so, resume instead of starting fresh
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
if (!options?.continuationPrompt) {
+ // If feature has an approved plan but we don't have a continuation prompt yet,
+ // we should build one to ensure it proceeds with multi-agent execution
+ if (feature.planSpec?.status === 'approved') {
+ logger.info(`Feature ${featureId} has approved plan, building continuation prompt`);
+
+ // Get customized prompts from settings
+ const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
+ const planContent = feature.planSpec.content || '';
+
+ // Build continuation prompt using centralized template
+ let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
+ continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, '');
+ continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
+
+ // Recursively call executeFeature with the continuation prompt
+ // Remove from running features temporarily, it will be added back
+ this.runningFeatures.delete(featureId);
+ return this.executeFeature(
+ projectPath,
+ featureId,
+ useWorktrees,
+ isAutoMode,
+ providedWorktreePath,
+ {
+ continuationPrompt,
+ }
+ );
+ }
+
const hasExistingContext = await this.contextExists(projectPath, featureId);
if (hasExistingContext) {
logger.info(
@@ -531,22 +1149,6 @@ export class AutoModeService {
}
}
- // Emit feature start event early
- this.emitAutoModeEvent('auto_mode_feature_start', {
- featureId,
- projectPath,
- feature: {
- id: featureId,
- title: 'Loading...',
- description: 'Feature is starting',
- },
- });
- // Load feature details FIRST to get branchName
- const feature = await this.loadFeature(projectPath, featureId);
- if (!feature) {
- throw new Error(`Feature ${featureId} not found`);
- }
-
// Derive workDir from feature.branchName
// Worktrees should already be created when the feature is added/edited
let worktreePath: string | null = null;
@@ -575,9 +1177,22 @@ export class AutoModeService {
tempRunningFeature.worktreePath = worktreePath;
tempRunningFeature.branchName = branchName ?? null;
- // Update feature status to in_progress
+ // Update feature status to in_progress BEFORE emitting event
+ // This ensures the frontend sees the updated status when it reloads features
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
+ // Emit feature start event AFTER status update so frontend sees correct status
+ this.emitAutoModeEvent('auto_mode_feature_start', {
+ featureId,
+ projectPath,
+ branchName: feature.branchName ?? null,
+ feature: {
+ id: featureId,
+ title: feature.title || 'Loading...',
+ description: feature.description || 'Feature is starting',
+ },
+ });
+
// Load autoLoadClaudeMd setting to determine context loading strategy
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
@@ -660,6 +1275,7 @@ export class AutoModeService {
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
+ branchName: feature.branchName ?? null,
}
);
@@ -721,6 +1337,8 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature.title,
+ branchName: feature.branchName ?? null,
passes: true,
message: `Feature completed in ${Math.round(
(Date.now() - tempRunningFeature.startTime) / 1000
@@ -735,6 +1353,8 @@ export class AutoModeService {
if (errorInfo.isAbort) {
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature?.title,
+ branchName: feature?.branchName ?? null,
passes: false,
message: 'Feature stopped by user',
projectPath,
@@ -744,6 +1364,8 @@ export class AutoModeService {
await this.updateFeatureStatus(projectPath, featureId, 'backlog');
this.emitAutoModeEvent('auto_mode_error', {
featureId,
+ featureName: feature?.title,
+ branchName: feature?.branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
@@ -825,6 +1447,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
+ branchName: feature.branchName ?? null,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
@@ -1064,6 +1687,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature.title,
+ branchName: feature.branchName ?? null,
passes: true,
message:
'Pipeline step no longer exists - feature completed without remaining pipeline steps',
@@ -1177,6 +1802,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_start', {
featureId,
projectPath,
+ branchName: branchName ?? null,
feature: {
id: featureId,
title: feature.title || 'Resuming Pipeline',
@@ -1186,8 +1812,9 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
- content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
projectPath,
+ branchName: branchName ?? null,
+ content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`,
});
// Load autoLoadClaudeMd setting
@@ -1216,6 +1843,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature.title,
+ branchName: feature.branchName ?? null,
passes: true,
message: 'Pipeline resumed and completed successfully',
projectPath,
@@ -1226,6 +1855,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
if (errorInfo.isAbort) {
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature.title,
+ branchName: feature.branchName ?? null,
passes: false,
message: 'Pipeline resume stopped by user',
projectPath,
@@ -1235,6 +1866,8 @@ Complete the pipeline step instructions above. Review the previous work and appl
await this.updateFeatureStatus(projectPath, featureId, 'backlog');
this.emitAutoModeEvent('auto_mode_error', {
featureId,
+ featureName: feature.title,
+ branchName: feature.branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
@@ -1356,22 +1989,25 @@ Address the follow-up instructions above. Review the previous work and make the
provider,
});
- this.emitAutoModeEvent('auto_mode_feature_start', {
- featureId,
- projectPath,
- feature: feature || {
- id: featureId,
- title: 'Follow-up',
- description: prompt.substring(0, 100),
- },
- model,
- provider,
- });
-
try {
- // Update feature status to in_progress
+ // Update feature status to in_progress BEFORE emitting event
+ // This ensures the frontend sees the updated status when it reloads features
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
+ // Emit feature start event AFTER status update so frontend sees correct status
+ this.emitAutoModeEvent('auto_mode_feature_start', {
+ featureId,
+ projectPath,
+ branchName,
+ feature: feature || {
+ id: featureId,
+ title: 'Follow-up',
+ description: prompt.substring(0, 100),
+ },
+ model,
+ provider,
+ });
+
// Copy follow-up images to feature folder
const copiedImagePaths: string[] = [];
if (imagePaths && imagePaths.length > 0) {
@@ -1465,6 +2101,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature?.title,
+ branchName: branchName ?? null,
passes: true,
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
projectPath,
@@ -1476,6 +2114,8 @@ Address the follow-up instructions above. Review the previous work and make the
if (!errorInfo.isCancellation) {
this.emitAutoModeEvent('auto_mode_error', {
featureId,
+ featureName: feature?.title,
+ branchName: branchName ?? null,
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
@@ -1503,8 +2143,13 @@ Address the follow-up instructions above. Review the previous work and make the
* Verify a feature's implementation
*/
async verifyFeature(projectPath: string, featureId: string): Promise {
+ // Load feature to get the name for event reporting
+ const feature = await this.loadFeature(projectPath, featureId);
+
// Worktrees are in project dir
- const worktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
let workDir = projectPath;
try {
@@ -1549,6 +2194,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature?.title,
+ branchName: feature?.branchName ?? null,
passes: allPassed,
message: allPassed
? 'All verification checks passed'
@@ -1585,7 +2232,9 @@ Address the follow-up instructions above. Review the previous work and make the
}
} else {
// Fallback: try to find worktree at legacy location
- const legacyWorktreePath = path.join(projectPath, '.worktrees', featureId);
+ // Sanitize featureId the same way it's sanitized when creating worktrees
+ const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
+ const legacyWorktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
try {
await secureFs.access(legacyWorktreePath);
workDir = legacyWorktreePath;
@@ -1625,6 +2274,8 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId,
+ featureName: feature?.title,
+ branchName: feature?.branchName ?? null,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath,
@@ -1663,6 +2314,7 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent('auto_mode_feature_start', {
featureId: analysisFeatureId,
projectPath,
+ branchName: null, // Project analysis is not worktree-specific
feature: {
id: analysisFeatureId,
title: 'Project Analysis',
@@ -1680,13 +2332,24 @@ Address the follow-up instructions above. Review the previous work and make the
Format your response as a structured markdown document.`;
try {
- // Get model from phase settings
- const settings = await this.settingsService?.getGlobalSettings();
- const phaseModelEntry =
- settings?.phaseModels?.projectAnalysisModel || DEFAULT_PHASE_MODELS.projectAnalysisModel;
+ // Get model from phase settings with provider info
+ const {
+ phaseModel: phaseModelEntry,
+ provider: analysisClaudeProvider,
+ credentials,
+ } = await getPhaseModelWithOverrides(
+ 'projectAnalysisModel',
+ this.settingsService,
+ projectPath,
+ '[AutoMode]'
+ );
const { model: analysisModel, thinkingLevel: analysisThinkingLevel } =
resolvePhaseModel(phaseModelEntry);
- logger.info('Using model for project analysis:', analysisModel);
+ logger.info(
+ 'Using model for project analysis:',
+ analysisModel,
+ analysisClaudeProvider ? `via provider: ${analysisClaudeProvider.name}` : 'direct API'
+ );
const provider = ProviderFactory.getProviderForModel(analysisModel);
@@ -1717,6 +2380,8 @@ Format your response as a structured markdown document.`;
abortController,
settingSources: sdkOptions.settingSources,
thinkingLevel: analysisThinkingLevel, // Pass thinking level
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider: analysisClaudeProvider, // Pass provider for alternative endpoint configuration
};
const stream = provider.executeQuery(options);
@@ -1747,6 +2412,8 @@ Format your response as a structured markdown document.`;
this.emitAutoModeEvent('auto_mode_feature_complete', {
featureId: analysisFeatureId,
+ featureName: 'Project Analysis',
+ branchName: null, // Project analysis is not worktree-specific
passes: true,
message: 'Project analysis completed',
projectPath,
@@ -1755,6 +2422,8 @@ Format your response as a structured markdown document.`;
const errorInfo = classifyError(error);
this.emitAutoModeEvent('auto_mode_error', {
featureId: analysisFeatureId,
+ featureName: 'Project Analysis',
+ branchName: null, // Project analysis is not worktree-specific
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
@@ -1777,6 +2446,71 @@ Format your response as a structured markdown document.`;
};
}
+ /**
+ * Get status for a specific project/worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ */
+ getStatusForProject(
+ projectPath: string,
+ branchName: string | null = null
+ ): {
+ isAutoLoopRunning: boolean;
+ runningFeatures: string[];
+ runningCount: number;
+ maxConcurrency: number;
+ branchName: string | null;
+ } {
+ const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
+ const projectState = this.autoLoopsByProject.get(worktreeKey);
+ const runningFeatures: string[] = [];
+
+ for (const [featureId, feature] of this.runningFeatures) {
+ // Filter by project path AND branchName to get worktree-specific features
+ if (feature.projectPath === projectPath && feature.branchName === branchName) {
+ runningFeatures.push(featureId);
+ }
+ }
+
+ return {
+ isAutoLoopRunning: projectState?.isRunning ?? false,
+ runningFeatures,
+ runningCount: runningFeatures.length,
+ maxConcurrency: projectState?.config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ branchName,
+ };
+ }
+
+ /**
+ * Get all active auto loop worktrees with their project paths and branch names
+ */
+ getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> {
+ const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = [];
+ for (const [, state] of this.autoLoopsByProject) {
+ if (state.isRunning) {
+ activeWorktrees.push({
+ projectPath: state.config.projectPath,
+ branchName: state.branchName,
+ });
+ }
+ }
+ return activeWorktrees;
+ }
+
+ /**
+ * Get all projects that have auto mode running (legacy, returns unique project paths)
+ * @deprecated Use getActiveAutoLoopWorktrees instead for full worktree information
+ */
+ getActiveAutoLoopProjects(): string[] {
+ const activeProjects = new Set();
+ for (const [, state] of this.autoLoopsByProject) {
+ if (state.isRunning) {
+ activeProjects.add(state.config.projectPath);
+ }
+ }
+ return Array.from(activeProjects);
+ }
+
/**
* Get detailed info about all running agents
*/
@@ -1790,22 +2524,25 @@ Format your response as a structured markdown document.`;
provider?: ModelProvider;
title?: string;
description?: string;
+ branchName?: string;
}>
> {
const agents = await Promise.all(
Array.from(this.runningFeatures.values()).map(async (rf) => {
- // Try to fetch feature data to get title and description
+ // Try to fetch feature data to get title, description, and branchName
let title: string | undefined;
let description: string | undefined;
+ let branchName: string | undefined;
try {
const feature = await this.featureLoader.get(rf.projectPath, rf.featureId);
if (feature) {
title = feature.title;
description = feature.description;
+ branchName = feature.branchName;
}
} catch (error) {
- // Silently ignore errors - title/description are optional
+ // Silently ignore errors - title/description/branchName are optional
}
return {
@@ -1817,6 +2554,7 @@ Format your response as a structured markdown document.`;
provider: rf.provider,
title,
description,
+ branchName,
};
})
);
@@ -2158,6 +2896,21 @@ Format your response as a structured markdown document.`;
}
}
+ private isFeatureFinished(feature: Feature): boolean {
+ const isCompleted = feature.status === 'completed' || feature.status === 'verified';
+
+ // Even if marked as completed, if it has an approved plan with pending tasks, it's not finished
+ if (feature.planSpec?.status === 'approved') {
+ const tasksCompleted = feature.planSpec.tasksCompleted ?? 0;
+ const tasksTotal = feature.planSpec.tasksTotal ?? 0;
+ if (tasksCompleted < tasksTotal) {
+ return false;
+ }
+ }
+
+ return isCompleted;
+ }
+
/**
* Update the planSpec of a feature
*/
@@ -2211,10 +2964,22 @@ Format your response as a structured markdown document.`;
}
}
- private async loadPendingFeatures(projectPath: string): Promise {
+ /**
+ * Load pending features for a specific project/worktree
+ * @param projectPath - The project path
+ * @param branchName - The branch name to filter by, or null for main worktree (features without branchName)
+ */
+ private async loadPendingFeatures(
+ projectPath: string,
+ branchName: string | null = null
+ ): Promise {
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
+ // Get the actual primary branch name for the project (e.g., "main", "master", "develop")
+ // This is needed to correctly match features when branchName is null (main worktree)
+ const primaryBranch = await getCurrentBranch(projectPath);
+
try {
const entries = await secureFs.readdir(featuresDir, {
withFileTypes: true,
@@ -2243,31 +3008,158 @@ Format your response as a structured markdown document.`;
allFeatures.push(feature);
- // Track pending features separately
+ // Track pending features separately, filtered by worktree/branch
+ // Note: waiting_approval is NOT included - those features have completed execution
+ // and are waiting for user review, they should not be picked up again
if (
feature.status === 'pending' ||
feature.status === 'ready' ||
- feature.status === 'backlog'
+ feature.status === 'backlog' ||
+ (feature.planSpec?.status === 'approved' &&
+ (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
) {
- pendingFeatures.push(feature);
+ // Filter by branchName:
+ // - If branchName is null (main worktree), include features with:
+ // - branchName === null, OR
+ // - branchName === primaryBranch (e.g., "main", "master", "develop")
+ // - If branchName is set, only include features with matching branchName
+ const featureBranch = feature.branchName ?? null;
+ if (branchName === null) {
+ // Main worktree: include features without branchName OR with branchName matching primary branch
+ // This handles repos where the primary branch is named something other than "main"
+ const isPrimaryBranch =
+ featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
+ if (isPrimaryBranch) {
+ pendingFeatures.push(feature);
+ } else {
+ logger.debug(
+ `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
+ );
+ }
+ } else {
+ // Feature worktree: include features with matching branchName
+ if (featureBranch === branchName) {
+ pendingFeatures.push(feature);
+ } else {
+ logger.debug(
+ `[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, expected: ${branchName}) for worktree ${branchName}`
+ );
+ }
+ }
}
}
}
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(
+ `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}`
+ );
+
+ if (pendingFeatures.length === 0) {
+ logger.warn(
+ `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}`
+ );
+ // Log all backlog features to help debug branchName matching
+ const allBacklogFeatures = allFeatures.filter(
+ (f) =>
+ f.status === 'backlog' ||
+ f.status === 'pending' ||
+ f.status === 'ready' ||
+ (f.planSpec?.status === 'approved' &&
+ (f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
+ );
+ if (allBacklogFeatures.length > 0) {
+ logger.info(
+ `[loadPendingFeatures] Found ${allBacklogFeatures.length} backlog features with branchNames: ${allBacklogFeatures.map((f) => `${f.id}(${f.branchName ?? 'null'})`).join(', ')}`
+ );
+ }
+ }
+
// Apply dependency-aware ordering
- const { orderedFeatures } = resolveDependencies(pendingFeatures);
+ const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
+
+ // Remove missing dependencies from features and save them
+ // This allows features to proceed when their dependencies have been deleted or don't exist
+ if (missingDependencies.size > 0) {
+ for (const [featureId, missingDepIds] of missingDependencies) {
+ const feature = pendingFeatures.find((f) => f.id === featureId);
+ if (feature && feature.dependencies) {
+ // Filter out the missing dependency IDs
+ const validDependencies = feature.dependencies.filter(
+ (depId) => !missingDepIds.includes(depId)
+ );
+
+ logger.warn(
+ `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
+ );
+
+ // Update the feature in memory
+ feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
+
+ // Save the updated feature to disk
+ try {
+ await this.featureLoader.update(projectPath, featureId, {
+ dependencies: feature.dependencies,
+ });
+ logger.info(
+ `[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
+ );
+ } catch (error) {
+ logger.error(
+ `[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
+ error
+ );
+ }
+ }
+ }
+ }
// Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings();
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
// Filter to only features with satisfied dependencies
- const readyFeatures = orderedFeatures.filter((feature: Feature) =>
- areDependenciesSatisfied(feature, allFeatures, { skipVerification })
+ const readyFeatures: Feature[] = [];
+ const blockedFeatures: Array<{ feature: Feature; reason: string }> = [];
+
+ for (const feature of orderedFeatures) {
+ const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification });
+ if (isSatisfied) {
+ readyFeatures.push(feature);
+ } else {
+ // Find which dependencies are blocking
+ const blockingDeps =
+ feature.dependencies?.filter((depId) => {
+ const dep = allFeatures.find((f) => f.id === depId);
+ if (!dep) return true; // Missing dependency
+ if (skipVerification) {
+ return dep.status === 'running';
+ }
+ return dep.status !== 'completed' && dep.status !== 'verified';
+ }) || [];
+ blockedFeatures.push({
+ feature,
+ reason:
+ blockingDeps.length > 0
+ ? `Blocked by dependencies: ${blockingDeps.join(', ')}`
+ : 'Unknown dependency issue',
+ });
+ }
+ }
+
+ if (blockedFeatures.length > 0) {
+ logger.info(
+ `[loadPendingFeatures] ${blockedFeatures.length} features blocked by dependencies: ${blockedFeatures.map((b) => `${b.feature.id} (${b.reason})`).join('; ')}`
+ );
+ }
+
+ logger.info(
+ `[loadPendingFeatures] After dependency filtering: ${readyFeatures.length} ready features (skipVerification=${skipVerification})`
);
return readyFeatures;
- } catch {
+ } catch (error) {
+ logger.error(`[loadPendingFeatures] Error loading features:`, error);
return [];
}
}
@@ -2396,9 +3288,11 @@ You can use the Read tool to view these images at any time during implementation
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
+ branchName?: string | null;
}
): Promise {
const finalProjectPath = options?.projectPath || projectPath;
+ const branchName = options?.branchName ?? null;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
@@ -2536,9 +3430,37 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
);
}
+ // Get credentials for API calls (model comes from request, no phase model)
+ const credentials = await this.settingsService?.getCredentials();
+
+ // Try to find a provider for the model (if it's a provider model like "GLM-4.7")
+ // This allows users to select provider models in the Auto Mode / Feature execution
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let providerResolvedModel: string | undefined;
+ if (finalModel && this.settingsService) {
+ const providerResult = await getProviderByModelId(
+ finalModel,
+ this.settingsService,
+ '[AutoMode]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ providerResolvedModel = providerResult.resolvedModel;
+ logger.info(
+ `[AutoMode] Using provider "${providerResult.provider.name}" for model "${finalModel}"` +
+ (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
+ );
+ }
+ }
+
+ // Use the resolved model if available (from mapsToClaudeModel), otherwise use bareModel
+ const effectiveBareModel = providerResolvedModel
+ ? stripProviderPrefix(providerResolvedModel)
+ : bareModel;
+
const executeOptions: ExecuteOptions = {
prompt: promptContent,
- model: bareModel,
+ model: effectiveBareModel,
maxTurns: maxTurns,
cwd: workDir,
allowedTools: allowedTools,
@@ -2547,6 +3469,8 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
settingSources: sdkOptions.settingSources,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
};
// Execute via provider
@@ -2754,6 +3678,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approval_required', {
featureId,
projectPath,
+ branchName,
planContent: currentPlanContent,
planningMode,
planVersion,
@@ -2785,6 +3710,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approved', {
featureId,
projectPath,
+ branchName,
hasEdits: !!approvalResult.editedPlan,
planVersion,
});
@@ -2813,6 +3739,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_revision_requested', {
featureId,
projectPath,
+ branchName,
feedback: approvalResult.feedback,
hasEdits: !!hasEdits,
planVersion,
@@ -2849,6 +3776,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
});
let revisionText = '';
@@ -2914,6 +3843,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('plan_auto_approved', {
featureId,
projectPath,
+ branchName,
planContent,
planningMode,
});
@@ -2964,6 +3894,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_started', {
featureId,
projectPath,
+ branchName,
taskId: task.id,
taskDescription: task.description,
taskIndex,
@@ -2994,6 +3925,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
});
let taskOutput = '';
@@ -3007,11 +3940,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
+ branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
+ branchName,
tool: block.name,
input: block.input,
});
@@ -3030,6 +3965,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_complete', {
featureId,
projectPath,
+ branchName,
taskId: task.id,
tasksCompleted: taskIndex + 1,
tasksTotal: parsedTasks.length,
@@ -3050,6 +3986,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_phase_complete', {
featureId,
projectPath,
+ branchName,
phaseNumber: parseInt(phaseMatch[1], 10),
});
}
@@ -3088,6 +4025,8 @@ After generating the revised spec, output:
allowedTools: allowedTools,
abortController,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
});
for await (const msg of continuationStream) {
@@ -3097,11 +4036,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
+ branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
+ branchName,
tool: block.name,
input: block.input,
});
@@ -3127,6 +4068,7 @@ After generating the revised spec, output:
);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
+ branchName,
content: block.text,
});
}
@@ -3134,6 +4076,7 @@ After generating the revised spec, output:
// Emit event for real-time UI
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
+ branchName,
tool: block.name,
input: block.input,
});
@@ -3420,8 +4363,9 @@ After generating the revised spec, output:
const state: ExecutionState = {
version: 1,
autoLoopWasRunning: this.autoLoopRunning,
- maxConcurrency: this.config?.maxConcurrency ?? 3,
+ maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
projectPath,
+ branchName: null, // Legacy global auto mode uses main worktree
runningFeatureIds: Array.from(this.runningFeatures.keys()),
savedAt: new Date().toISOString(),
};
@@ -3452,11 +4396,15 @@ After generating the revised spec, output:
/**
* Clear execution state (called on successful shutdown or when auto-loop stops)
*/
- private async clearExecutionState(projectPath: string): Promise {
+ private async clearExecutionState(
+ projectPath: string,
+ branchName: string | null = null
+ ): Promise {
try {
const statePath = getExecutionStatePath(projectPath);
await secureFs.unlink(statePath);
- logger.info('Cleared execution state');
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(`Cleared execution state for ${worktreeDesc}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to clear execution state:', error);
@@ -3534,6 +4482,7 @@ After generating the revised spec, output:
id: f.id,
title: f.title,
status: f.status,
+ branchName: f.branchName ?? null,
})),
});
diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts
index aebed98b..aa8afc1c 100644
--- a/apps/server/src/services/claude-usage-service.ts
+++ b/apps/server/src/services/claude-usage-service.ts
@@ -468,10 +468,41 @@ export class ClaudeUsageService {
/**
* Strip ANSI escape codes from text
+ * Handles CSI, OSC, and other common ANSI sequences
*/
private stripAnsiCodes(text: string): string {
+ // First strip ANSI sequences (colors, etc) and handle CR
// eslint-disable-next-line no-control-regex
- return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
+ let clean = text
+ // CSI sequences: ESC [ ... (letter or @)
+ .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
+ // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
+ .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
+ // Other ESC sequences: ESC (letter)
+ .replace(/\x1B[A-Za-z]/g, '')
+ // Carriage returns: replace with newline to avoid concatenation
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n');
+
+ // Handle backspaces (\x08) by applying them
+ // If we encounter a backspace, remove the character before it
+ while (clean.includes('\x08')) {
+ clean = clean.replace(/[^\x08]\x08/, '');
+ clean = clean.replace(/^\x08+/, '');
+ }
+
+ // Explicitly strip known "Synchronized Output" and "Window Title" garbage
+ // even if ESC is missing (seen in some environments)
+ clean = clean
+ .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l
+ .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL
+ .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence)
+
+ // Strip remaining non-printable control characters (except newline \n)
+ // ASCII 0-8, 11-31, 127
+ clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
+
+ return clean;
}
/**
@@ -550,7 +581,7 @@ export class ClaudeUsageService {
sectionLabel: string,
type: string
): { percentage: number; resetTime: string; resetText: string } {
- let percentage = 0;
+ let percentage: number | null = null;
let resetTime = this.getDefaultResetTime(type);
let resetText = '';
@@ -564,7 +595,7 @@ export class ClaudeUsageService {
}
if (sectionIndex === -1) {
- return { percentage, resetTime, resetText };
+ return { percentage: 0, resetTime, resetText };
}
// Look at the lines following the section header (within a window of 5 lines)
@@ -572,7 +603,8 @@ export class ClaudeUsageService {
for (const line of searchWindow) {
// Extract percentage - only take the first match (avoid picking up next section's data)
- if (percentage === 0) {
+ // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used)
+ if (percentage === null) {
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
if (percentMatch) {
const value = parseInt(percentMatch[1], 10);
@@ -584,18 +616,31 @@ export class ClaudeUsageService {
// Extract reset time - only take the first match
if (!resetText && line.toLowerCase().includes('reset')) {
- resetText = line;
+ // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes
+ const match = line.match(/(Resets?.*)$/i);
+ // If regex fails despite 'includes', likely a complex string issues - verify match before using line
+ // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage
+ if (match) {
+ resetText = match[1];
+ }
}
}
// Parse the reset time if we found one
if (resetText) {
+ // Clean up resetText: remove percentage info if it was matched on the same line
+ // e.g. "46%used Resets5:59pm" -> " Resets5:59pm"
+ resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim();
+
+ // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm")
+ resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2');
+
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
}
- return { percentage, resetTime, resetText };
+ return { percentage: percentage ?? 0, resetTime, resetText };
}
/**
@@ -624,7 +669,7 @@ export class ClaudeUsageService {
}
// Try to parse simple time-only format: "Resets 11am" or "Resets 3pm"
- const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
+ const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (simpleTimeMatch) {
let hours = parseInt(simpleTimeMatch[1], 10);
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
@@ -649,8 +694,11 @@ export class ClaudeUsageService {
}
// Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm"
+ // The regex explicitly matches only valid 3-letter month abbreviations to avoid
+ // matching words like "Resets" when there's no space separator.
+ // Optional "resets\s*" prefix handles cases with or without space after "Resets"
const dateMatch = text.match(
- /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
+ /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
);
if (dateMatch) {
const monthName = dateMatch[1];
diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts
index 08da71dd..9f73155f 100644
--- a/apps/server/src/services/event-hook-service.ts
+++ b/apps/server/src/services/event-hook-service.ts
@@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
+import type { FeatureLoader } from './feature-loader.js';
import type {
EventHook,
EventHookTrigger,
@@ -57,6 +58,7 @@ interface HookContext {
interface AutoModeEventPayload {
type?: string;
featureId?: string;
+ featureName?: string;
passes?: boolean;
message?: string;
error?: string;
@@ -83,19 +85,22 @@ export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
+ private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | null = null;
/**
- * Initialize the service with event emitter, settings service, and event history service
+ * Initialize the service with event emitter, settings service, event history service, and feature loader
*/
initialize(
emitter: EventEmitter,
settingsService: SettingsService,
- eventHistoryService?: EventHistoryService
+ eventHistoryService?: EventHistoryService,
+ featureLoader?: FeatureLoader
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
+ this.featureLoader = featureLoader || null;
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
@@ -120,6 +125,7 @@ export class EventHookService {
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
+ this.featureLoader = null;
}
/**
@@ -149,9 +155,23 @@ export class EventHookService {
if (!trigger) return;
+ // Load feature name if we have featureId but no featureName
+ let featureName: string | undefined = undefined;
+ if (payload.featureId && payload.projectPath && this.featureLoader) {
+ try {
+ const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
+ if (feature?.title) {
+ featureName = feature.title;
+ }
+ } catch (error) {
+ logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
+ }
+ }
+
// Build context for variable substitution
const context: HookContext = {
featureId: payload.featureId,
+ featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message,
@@ -313,6 +333,7 @@ export class EventHookService {
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
+ featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts
index 4ef3d8a8..0a6a8471 100644
--- a/apps/server/src/services/ideation-service.ts
+++ b/apps/server/src/services/ideation-service.ts
@@ -41,7 +41,7 @@ import type { FeatureLoader } from './feature-loader.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { resolveModelString } from '@automaker/model-resolver';
import { stripProviderPrefix } from '@automaker/types';
-import { getPromptCustomization } from '../lib/settings-helpers.js';
+import { getPromptCustomization, getProviderByModelId } from '../lib/settings-helpers.js';
const logger = createLogger('IdeationService');
@@ -208,7 +208,27 @@ export class IdeationService {
);
// Resolve model alias to canonical identifier (with prefix)
- const modelId = resolveModelString(options?.model ?? 'sonnet');
+ let modelId = resolveModelString(options?.model ?? 'sonnet');
+
+ // Try to find a provider for this model (e.g., GLM, MiniMax models)
+ let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
+ let credentials = await this.settingsService?.getCredentials();
+
+ if (this.settingsService && options?.model) {
+ const providerResult = await getProviderByModelId(
+ options.model,
+ this.settingsService,
+ '[IdeationService]'
+ );
+ if (providerResult.provider) {
+ claudeCompatibleProvider = providerResult.provider;
+ // Use resolved model from provider if available (maps to Claude model)
+ if (providerResult.resolvedModel) {
+ modelId = providerResult.resolvedModel;
+ }
+ credentials = providerResult.credentials ?? credentials;
+ }
+ }
// Create SDK options
const sdkOptions = createChatOptions({
@@ -232,6 +252,8 @@ export class IdeationService {
maxTurns: 1, // Single turn for ideation
abortController: activeSession.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
+ claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
@@ -678,6 +700,9 @@ export class IdeationService {
// Strip provider prefix - providers need bare model IDs
const bareModel = stripProviderPrefix(modelId);
+ // Get credentials for API calls (uses hardcoded model, no phase setting)
+ const credentials = await this.settingsService?.getCredentials();
+
const executeOptions: ExecuteOptions = {
prompt: prompt.prompt,
model: bareModel,
@@ -688,6 +713,7 @@ export class IdeationService {
// Disable all tools - we just want text generation, not codebase analysis
allowedTools: [],
abortController: new AbortController(),
+ credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
const stream = provider.executeQuery(executeOptions);
diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts
index e63b075c..8c760c70 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,
@@ -28,6 +31,9 @@ import type {
WorktreeInfo,
PhaseModelConfig,
PhaseModelEntry,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
+ ProviderModel,
} from '../types/settings.js';
import {
DEFAULT_GLOBAL_SETTINGS,
@@ -38,6 +44,12 @@ import {
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
} from '../types/settings.js';
+import {
+ DEFAULT_MAX_CONCURRENCY,
+ migrateModelId,
+ migrateCursorModelIds,
+ migrateOpencodeModelIds,
+} from '@automaker/types';
const logger = createLogger('SettingsService');
@@ -124,10 +136,14 @@ export class SettingsService {
// Migrate legacy enhancementModel/validationModel to phaseModels
const migratedPhaseModels = this.migratePhaseModels(settings);
+ // Migrate model IDs to canonical format
+ const migratedModelSettings = this.migrateModelSettings(settings);
+
// Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
...settings,
+ ...migratedModelSettings,
keyboardShortcuts: {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
@@ -158,6 +174,63 @@ export class SettingsService {
needsSave = true;
}
+ // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
+ // If user has an Anthropic API key in credentials but no profiles, create a
+ // "Direct Anthropic" profile that references the credentials and set it as active.
+ if (storedVersion < 5) {
+ try {
+ const credentials = await this.getCredentials();
+ const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
+ const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
+ const hasNoActiveProfile = !result.activeClaudeApiProfileId;
+
+ if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
+ const directAnthropicProfile = {
+ id: `profile-${Date.now()}-direct-anthropic`,
+ name: 'Direct Anthropic',
+ baseUrl: 'https://api.anthropic.com',
+ apiKeySource: 'credentials' as const,
+ useAuthToken: false,
+ };
+
+ result.claudeApiProfiles = [directAnthropicProfile];
+ result.activeClaudeApiProfileId = directAnthropicProfile.id;
+
+ logger.info(
+ 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
+ );
+ }
+ } catch (error) {
+ logger.warn(
+ 'Migration v4->v5: Could not check credentials for auto-profile creation:',
+ error
+ );
+ }
+ needsSave = true;
+ }
+
+ // Migration v5 -> v6: Convert claudeApiProfiles to claudeCompatibleProviders
+ // The new system uses a models[] array instead of modelMappings, and removes
+ // the "active profile" concept - models are selected directly in phase model configs.
+ if (storedVersion < 6) {
+ const legacyProfiles = settings.claudeApiProfiles || [];
+ if (
+ legacyProfiles.length > 0 &&
+ (!result.claudeCompatibleProviders || result.claudeCompatibleProviders.length === 0)
+ ) {
+ logger.info(
+ `Migration v5->v6: Converting ${legacyProfiles.length} Claude API profile(s) to compatible providers`
+ );
+ result.claudeCompatibleProviders = this.migrateProfilesToProviders(legacyProfiles);
+ }
+ // Remove the deprecated activeClaudeApiProfileId field
+ if (result.activeClaudeApiProfileId) {
+ logger.info('Migration v5->v6: Removing deprecated activeClaudeApiProfileId');
+ delete result.activeClaudeApiProfileId;
+ }
+ needsSave = true;
+ }
+
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -223,19 +296,203 @@ 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 ClaudeApiProfiles to ClaudeCompatibleProviders
+ *
+ * Converts the legacy profile format (with modelMappings) to the new
+ * provider format (with models[] array). Each model mapping entry becomes
+ * a ProviderModel with appropriate tier assignment.
+ *
+ * @param profiles - Legacy ClaudeApiProfile array
+ * @returns Array of ClaudeCompatibleProvider
+ */
+ private migrateProfilesToProviders(profiles: ClaudeApiProfile[]): ClaudeCompatibleProvider[] {
+ return profiles.map((profile): ClaudeCompatibleProvider => {
+ // Convert modelMappings to models array
+ const models: ProviderModel[] = [];
+
+ if (profile.modelMappings) {
+ // Haiku mapping
+ if (profile.modelMappings.haiku) {
+ models.push({
+ id: profile.modelMappings.haiku,
+ displayName: this.inferModelDisplayName(profile.modelMappings.haiku, 'haiku'),
+ mapsToClaudeModel: 'haiku',
+ });
+ }
+ // Sonnet mapping
+ if (profile.modelMappings.sonnet) {
+ models.push({
+ id: profile.modelMappings.sonnet,
+ displayName: this.inferModelDisplayName(profile.modelMappings.sonnet, 'sonnet'),
+ mapsToClaudeModel: 'sonnet',
+ });
+ }
+ // Opus mapping
+ if (profile.modelMappings.opus) {
+ models.push({
+ id: profile.modelMappings.opus,
+ displayName: this.inferModelDisplayName(profile.modelMappings.opus, 'opus'),
+ mapsToClaudeModel: 'opus',
+ });
+ }
+ }
+
+ // Infer provider type from base URL or name
+ const providerType = this.inferProviderType(profile);
+
+ return {
+ id: profile.id,
+ name: profile.name,
+ providerType,
+ enabled: true,
+ baseUrl: profile.baseUrl,
+ apiKeySource: profile.apiKeySource ?? 'inline',
+ apiKey: profile.apiKey,
+ useAuthToken: profile.useAuthToken,
+ timeoutMs: profile.timeoutMs,
+ disableNonessentialTraffic: profile.disableNonessentialTraffic,
+ models,
+ };
+ });
+ }
+
+ /**
+ * Infer a display name for a model based on its ID and tier
+ *
+ * @param modelId - The raw model ID
+ * @param tier - The tier hint (haiku/sonnet/opus)
+ * @returns A user-friendly display name
+ */
+ private inferModelDisplayName(modelId: string, tier: 'haiku' | 'sonnet' | 'opus'): string {
+ // Common patterns in model IDs
+ const lowerModelId = modelId.toLowerCase();
+
+ // GLM models
+ if (lowerModelId.includes('glm')) {
+ return modelId.replace(/-/g, ' ').replace(/glm/i, 'GLM');
+ }
+
+ // MiniMax models
+ if (lowerModelId.includes('minimax')) {
+ return modelId.replace(/-/g, ' ').replace(/minimax/i, 'MiniMax');
+ }
+
+ // Claude models via OpenRouter or similar
+ if (lowerModelId.includes('claude')) {
+ return modelId;
+ }
+
+ // Default: use model ID as display name with tier in parentheses
+ return `${modelId} (${tier})`;
+ }
+
+ /**
+ * Infer provider type from profile configuration
+ *
+ * @param profile - The legacy profile
+ * @returns The inferred provider type
+ */
+ private inferProviderType(profile: ClaudeApiProfile): ClaudeCompatibleProvider['providerType'] {
+ const baseUrl = profile.baseUrl.toLowerCase();
+ const name = profile.name.toLowerCase();
+
+ // Check URL patterns
+ if (baseUrl.includes('z.ai') || baseUrl.includes('zhipuai')) {
+ return 'glm';
+ }
+ if (baseUrl.includes('minimax')) {
+ return 'minimax';
+ }
+ if (baseUrl.includes('openrouter')) {
+ return 'openrouter';
+ }
+ if (baseUrl.includes('anthropic.com')) {
+ return 'anthropic';
+ }
+
+ // Check name patterns
+ if (name.includes('glm') || name.includes('zhipu')) {
+ return 'glm';
+ }
+ if (name.includes('minimax')) {
+ return 'minimax';
+ }
+ if (name.includes('openrouter')) {
+ return 'openrouter';
+ }
+ if (name.includes('anthropic') || name.includes('direct')) {
+ return 'anthropic';
+ }
+
+ // Default to custom
+ return 'custom';
+ }
+
+ /**
+ * 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 +530,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');
@@ -287,18 +570,29 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
+ ignoreEmptyArrayOverwrite('claudeApiProfiles');
+ // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
// Empty object overwrite guard
- if (
- sanitizedUpdates.lastSelectedSessionByProject &&
- typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
- !Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
- Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
- current.lastSelectedSessionByProject &&
- Object.keys(current.lastSelectedSessionByProject).length > 0
- ) {
- delete sanitizedUpdates.lastSelectedSessionByProject;
- }
+ const ignoreEmptyObjectOverwrite = (key: K): void => {
+ const nextVal = sanitizedUpdates[key] as unknown;
+ const curVal = current[key] as unknown;
+ if (
+ nextVal &&
+ typeof nextVal === 'object' &&
+ !Array.isArray(nextVal) &&
+ Object.keys(nextVal).length === 0 &&
+ curVal &&
+ typeof curVal === 'object' &&
+ !Array.isArray(curVal) &&
+ Object.keys(curVal).length > 0
+ ) {
+ delete sanitizedUpdates[key];
+ }
+ };
+
+ ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
+ ignoreEmptyObjectOverwrite('autoModeByWorktree');
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {
@@ -512,6 +806,27 @@ export class SettingsService {
};
}
+ // Handle activeClaudeApiProfileId special cases:
+ // - "__USE_GLOBAL__" marker means delete the key (use global setting)
+ // - null means explicit "Direct Anthropic API"
+ // - string means specific profile ID
+ if (
+ 'activeClaudeApiProfileId' in updates &&
+ updates.activeClaudeApiProfileId === '__USE_GLOBAL__'
+ ) {
+ delete updated.activeClaudeApiProfileId;
+ }
+
+ // Handle phaseModelOverrides special cases:
+ // - "__CLEAR__" marker means delete the key (use global settings for all phases)
+ // - object means partial overrides for specific phases
+ if (
+ 'phaseModelOverrides' in updates &&
+ (updates as Record).phaseModelOverrides === '__CLEAR__'
+ ) {
+ delete updated.phaseModelOverrides;
+ }
+
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);
@@ -597,7 +912,7 @@ export class SettingsService {
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
- maxConcurrency: (appState.maxConcurrency as number) || 3,
+ maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY,
defaultSkipTests:
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
enableDependencyBlocking:
@@ -766,4 +1081,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/src/types/settings.ts b/apps/server/src/types/settings.ts
index 98bce97f..6863b314 100644
--- a/apps/server/src/types/settings.ts
+++ b/apps/server/src/types/settings.ts
@@ -23,6 +23,16 @@ export type {
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
+ // Claude-compatible provider types
+ ApiKeySource,
+ ClaudeCompatibleProviderType,
+ ClaudeModelAlias,
+ ProviderModel,
+ ClaudeCompatibleProvider,
+ ClaudeCompatibleProviderTemplate,
+ // Legacy profile types (deprecated)
+ ClaudeApiProfile,
+ ClaudeApiProfileTemplate,
} from '@automaker/types';
export {
diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts
index c2ea6123..c1bff78d 100644
--- a/apps/server/tests/unit/lib/model-resolver.test.ts
+++ b/apps/server/tests/unit/lib/model-resolver.test.ts
@@ -37,17 +37,18 @@ 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"')
);
});
- it('should treat unknown models as falling back to default', () => {
- // Note: Don't include valid Cursor model IDs here (e.g., 'gpt-5.2' is in CURSOR_MODEL_MAP)
- const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123'];
+ it('should pass through unknown models unchanged (may be provider models)', () => {
+ // Unknown models now pass through unchanged to support ClaudeCompatibleProvider models
+ // like GLM-4.7, MiniMax-M2.1, o1, etc.
+ const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123', 'GLM-4.7'];
models.forEach((model) => {
const result = resolveModelString(model);
- // Should fall back to default since these aren't supported
- expect(result).toBe(DEFAULT_MODELS.claude);
+ // Should pass through unchanged (could be provider models)
+ expect(result).toBe(model);
});
});
@@ -73,12 +74,12 @@ describe('model-resolver.ts', () => {
expect(result).toBe(customDefault);
});
- it('should return default for unknown model key', () => {
+ it('should pass through unknown model key unchanged (no warning)', () => {
const result = resolveModelString('unknown-model');
- expect(result).toBe(DEFAULT_MODELS.claude);
- expect(consoleSpy.warn).toHaveBeenCalledWith(
- expect.stringContaining('Unknown model key "unknown-model"')
- );
+ // Unknown models pass through unchanged (could be provider models)
+ expect(result).toBe('unknown-model');
+ // No warning - unknown models are valid for providers
+ expect(consoleSpy.warn).not.toHaveBeenCalled();
});
it('should handle empty string', () => {
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/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts
index 57e2fc38..641838ef 100644
--- a/apps/server/tests/unit/providers/opencode-provider.test.ts
+++ b/apps/server/tests/unit/providers/opencode-provider.test.ts
@@ -1311,4 +1311,317 @@ describe('opencode-provider.ts', () => {
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
});
});
+
+ // ==========================================================================
+ // parseProvidersOutput Tests
+ // ==========================================================================
+
+ describe('parseProvidersOutput', () => {
+ // Helper function to access private method
+ function parseProviders(output: string) {
+ return (
+ provider as unknown as {
+ parseProvidersOutput: (output: string) => Array<{
+ id: string;
+ name: string;
+ authenticated: boolean;
+ authMethod?: 'oauth' | 'api_key';
+ }>;
+ }
+ ).parseProvidersOutput(output);
+ }
+
+ // =======================================================================
+ // Critical Fix Validation
+ // =======================================================================
+
+ describe('Critical Fix Validation', () => {
+ it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => {
+ const output = '● z.ai coding plan oauth';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('zai-coding-plan');
+ expect(result[0].name).toBe('z.ai coding plan');
+ expect(result[0].authMethod).toBe('oauth');
+ });
+
+ it('should map "z.ai" to "z-ai" (different from coding plan)', () => {
+ const output = '● z.ai api';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('z-ai');
+ expect(result[0].name).toBe('z.ai');
+ expect(result[0].authMethod).toBe('api_key');
+ });
+
+ it('should distinguish between "z.ai coding plan" and "z.ai"', () => {
+ const output = '● z.ai coding plan oauth\n● z.ai api';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('zai-coding-plan');
+ expect(result[0].name).toBe('z.ai coding plan');
+ expect(result[1].id).toBe('z-ai');
+ expect(result[1].name).toBe('z.ai');
+ });
+ });
+
+ // =======================================================================
+ // Provider Name Mapping
+ // =======================================================================
+
+ describe('Provider Name Mapping', () => {
+ it('should map all 12 providers correctly', () => {
+ const output = `● anthropic oauth
+● github copilot oauth
+● google api
+● openai api
+● openrouter api
+● azure api
+● amazon bedrock oauth
+● ollama api
+● lm studio api
+● opencode oauth
+● z.ai coding plan oauth
+● z.ai api`;
+
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(12);
+ expect(result.map((p) => p.id)).toEqual([
+ 'anthropic',
+ 'github-copilot',
+ 'google',
+ 'openai',
+ 'openrouter',
+ 'azure',
+ 'amazon-bedrock',
+ 'ollama',
+ 'lmstudio',
+ 'opencode',
+ 'zai-coding-plan',
+ 'z-ai',
+ ]);
+ });
+
+ it('should handle case-insensitive provider names and preserve original casing', () => {
+ const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(3);
+ expect(result[0].id).toBe('anthropic');
+ expect(result[0].name).toBe('Anthropic'); // Preserves casing
+ expect(result[1].id).toBe('openai');
+ expect(result[1].name).toBe('OPENAI'); // Preserves casing
+ expect(result[2].id).toBe('github-copilot');
+ expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing
+ });
+
+ it('should handle multi-word provider names with spaces', () => {
+ const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth';
+ const result = parseProviders(output);
+
+ expect(result[0].id).toBe('amazon-bedrock');
+ expect(result[0].name).toBe('Amazon Bedrock');
+ expect(result[1].id).toBe('lmstudio');
+ expect(result[1].name).toBe('LM Studio');
+ expect(result[2].id).toBe('github-copilot');
+ expect(result[2].name).toBe('GitHub Copilot');
+ });
+ });
+
+ // =======================================================================
+ // Duplicate Aliases
+ // =======================================================================
+
+ describe('Duplicate Aliases', () => {
+ it('should map provider aliases to the same ID', () => {
+ // Test copilot variants
+ const copilot1 = parseProviders('● copilot oauth');
+ const copilot2 = parseProviders('● github copilot oauth');
+ expect(copilot1[0].id).toBe('github-copilot');
+ expect(copilot2[0].id).toBe('github-copilot');
+
+ // Test bedrock variants
+ const bedrock1 = parseProviders('● bedrock oauth');
+ const bedrock2 = parseProviders('● amazon bedrock oauth');
+ expect(bedrock1[0].id).toBe('amazon-bedrock');
+ expect(bedrock2[0].id).toBe('amazon-bedrock');
+
+ // Test lmstudio variants
+ const lm1 = parseProviders('● lmstudio api');
+ const lm2 = parseProviders('● lm studio api');
+ expect(lm1[0].id).toBe('lmstudio');
+ expect(lm2[0].id).toBe('lmstudio');
+ });
+ });
+
+ // =======================================================================
+ // Authentication Methods
+ // =======================================================================
+
+ describe('Authentication Methods', () => {
+ it('should detect oauth and api_key auth methods', () => {
+ const output = '● anthropic oauth\n● openai api\n● google api_key';
+ const result = parseProviders(output);
+
+ expect(result[0].authMethod).toBe('oauth');
+ expect(result[1].authMethod).toBe('api_key');
+ expect(result[2].authMethod).toBe('api_key');
+ });
+
+ it('should set authenticated to true and handle case-insensitive auth methods', () => {
+ const output = '● anthropic OAuth\n● openai API';
+ const result = parseProviders(output);
+
+ expect(result[0].authenticated).toBe(true);
+ expect(result[0].authMethod).toBe('oauth');
+ expect(result[1].authenticated).toBe(true);
+ expect(result[1].authMethod).toBe('api_key');
+ });
+
+ it('should return undefined authMethod for unknown auth types', () => {
+ const output = '● anthropic unknown-auth';
+ const result = parseProviders(output);
+
+ expect(result[0].authenticated).toBe(true);
+ expect(result[0].authMethod).toBeUndefined();
+ });
+ });
+
+ // =======================================================================
+ // ANSI Escape Sequences
+ // =======================================================================
+
+ describe('ANSI Escape Sequences', () => {
+ it('should strip ANSI color codes from output', () => {
+ const output = '\x1b[32m● anthropic oauth\x1b[0m';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('anthropic');
+ expect(result[0].name).toBe('anthropic');
+ });
+
+ it('should handle complex ANSI sequences and codes in provider names', () => {
+ const output =
+ '\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('github-copilot');
+ });
+ });
+
+ // =======================================================================
+ // Edge Cases
+ // =======================================================================
+
+ describe('Edge Cases', () => {
+ it('should return empty array for empty output or no ● symbols', () => {
+ expect(parseProviders('')).toEqual([]);
+ expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]);
+ expect(parseProviders('No authenticated providers')).toEqual([]);
+ });
+
+ it('should skip malformed lines with ● but insufficient content', () => {
+ const output = '●\n● \n● anthropic\n● openai api';
+ const result = parseProviders(output);
+
+ // Only the last line has both provider name and auth method
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('openai');
+ });
+
+ it('should use fallback for unknown providers (spaces to hyphens)', () => {
+ const output = '● unknown provider name oauth';
+ const result = parseProviders(output);
+
+ expect(result[0].id).toBe('unknown-provider-name');
+ expect(result[0].name).toBe('unknown provider name');
+ });
+
+ it('should handle extra whitespace and mixed case', () => {
+ const output = '● AnThRoPiC oauth';
+ const result = parseProviders(output);
+
+ expect(result[0].id).toBe('anthropic');
+ expect(result[0].name).toBe('AnThRoPiC');
+ });
+
+ it('should handle multiple ● symbols on same line', () => {
+ const output = '● ● anthropic oauth';
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('anthropic');
+ });
+
+ it('should handle different newline formats and trailing newlines', () => {
+ const outputUnix = '● anthropic oauth\n● openai api';
+ const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n';
+
+ const resultUnix = parseProviders(outputUnix);
+ const resultWindows = parseProviders(outputWindows);
+
+ expect(resultUnix).toHaveLength(2);
+ expect(resultWindows).toHaveLength(2);
+ });
+
+ it('should handle provider names with numbers and special characters', () => {
+ const output = '● gpt-4o api';
+ const result = parseProviders(output);
+
+ expect(result[0].id).toBe('gpt-4o');
+ expect(result[0].name).toBe('gpt-4o');
+ });
+ });
+
+ // =======================================================================
+ // Real-world CLI Output
+ // =======================================================================
+
+ describe('Real-world CLI Output', () => {
+ it('should parse CLI output with box drawing characters and decorations', () => {
+ const output = `┌─────────────────────────────────────────────────┐
+│ Authenticated Providers │
+├─────────────────────────────────────────────────┤
+● anthropic oauth
+● openai api
+└─────────────────────────────────────────────────┘`;
+
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('anthropic');
+ expect(result[1].id).toBe('openai');
+ });
+
+ it('should parse output with ANSI colors and box characters', () => {
+ const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m
+\x1b[1m│ Authenticated Providers │\x1b[0m
+\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m
+\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m
+\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m
+\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`;
+
+ const result = parseProviders(output);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('anthropic');
+ expect(result[1].id).toBe('google');
+ });
+
+ it('should handle "no authenticated providers" message', () => {
+ const output = `┌─────────────────────────────────────────────────┐
+│ No authenticated providers found │
+└─────────────────────────────────────────────────┘`;
+
+ const result = parseProviders(output);
+ expect(result).toEqual([]);
+ });
+ });
+ });
});
diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts
index 07ad13c9..7901192c 100644
--- a/apps/server/tests/unit/services/claude-usage-service.test.ts
+++ b/apps/server/tests/unit/services/claude-usage-service.test.ts
@@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => {
expect(result).toBe('Plain text');
});
+
+ it('should strip OSC sequences (window title, etc.)', () => {
+ const service = new ClaudeUsageService();
+ // OSC sequence to set window title: ESC ] 0 ; title BEL
+ const input = '\x1B]0;Claude Code\x07Regular text';
+ // @ts-expect-error - accessing private method for testing
+ const result = service.stripAnsiCodes(input);
+
+ expect(result).toBe('Regular text');
+ });
+
+ it('should strip DEC private mode sequences', () => {
+ const service = new ClaudeUsageService();
+ // DEC private mode sequences like ESC[?2026h and ESC[?2026l
+ const input = '\x1B[?2026lClaude Code\x1B[?2026h more text';
+ // @ts-expect-error - accessing private method for testing
+ const result = service.stripAnsiCodes(input);
+
+ expect(result).toBe('Claude Code more text');
+ });
+
+ it('should handle complex terminal output with mixed escape sequences', () => {
+ const service = new ClaudeUsageService();
+ // Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h"
+ // This contains OSC (set title) and DEC private mode sequences
+ const input =
+ '\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am';
+ // @ts-expect-error - accessing private method for testing
+ const result = service.stripAnsiCodes(input);
+
+ expect(result).toBe('Current session 0%used Resets3am');
+ });
+
+ it('should strip single character escape sequences', () => {
+ const service = new ClaudeUsageService();
+ // ESC c is the reset terminal command
+ const input = '\x1BcReset text';
+ // @ts-expect-error - accessing private method for testing
+ const result = service.stripAnsiCodes(input);
+
+ expect(result).toBe('Reset text');
+ });
+
+ it('should remove control characters but preserve newlines and tabs', () => {
+ const service = new ClaudeUsageService();
+ // BEL character (\x07) should be stripped, but the word "Bell" is regular text
+ const input = 'Line 1\nLine 2\tTabbed\x07 with bell';
+ // @ts-expect-error - accessing private method for testing
+ const result = service.stripAnsiCodes(input);
+
+ // BEL is stripped, newlines and tabs preserved
+ expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
+ });
});
describe('parseResetTime', () => {
diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts
index 346fe442..6b862fa5 100644
--- a/apps/server/tests/unit/services/ideation-service.test.ts
+++ b/apps/server/tests/unit/services/ideation-service.test.ts
@@ -63,7 +63,10 @@ describe('IdeationService', () => {
} as unknown as EventEmitter;
// Create mock settings service
- mockSettingsService = {} as SettingsService;
+ mockSettingsService = {
+ getCredentials: vi.fn().mockResolvedValue({}),
+ getGlobalSettings: vi.fn().mockResolvedValue({}),
+ } as unknown as SettingsService;
// Create mock feature loader
mockFeatureLoader = {
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..e66433fd 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",
@@ -79,7 +80,8 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
- "@tanstack/react-query": "5.90.12",
+ "@tanstack/react-query": "^5.90.17",
+ "@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "1.141.6",
"@uiw/react-codemirror": "4.25.4",
"@xterm/addon-fit": "0.10.0",
@@ -145,6 +147,7 @@
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
+ "publish": null,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"directories": {
"output": "release"
diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx
index d51e316c..5beaac94 100644
--- a/apps/ui/src/components/claude-usage-popover.tsx
+++ b/apps/ui/src/components/claude-usage-popover.tsx
@@ -1,114 +1,40 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
+/**
+ * Claude Usage Popover
+ *
+ * Displays Claude API usage statistics using React Query for data fetching.
+ */
+
+import { useState, useMemo } 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';
import { useSetupStore } from '@/store/setup-store';
-
-// Error codes for distinguishing failure modes
-const ERROR_CODES = {
- API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
- AUTH_ERROR: 'AUTH_ERROR',
- TRUST_PROMPT: 'TRUST_PROMPT',
- UNKNOWN: 'UNKNOWN',
-} as const;
-
-type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
-
-type UsageError = {
- code: ErrorCode;
- message: string;
-};
-
-// Fixed refresh interval (45 seconds)
-const REFRESH_INTERVAL_SECONDS = 45;
+import { useClaudeUsage } from '@/hooks/queries';
export function ClaudeUsagePopover() {
- const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
- // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
+ // Use React Query for usage data
+ const {
+ data: claudeUsage,
+ isLoading,
+ isFetching,
+ error,
+ dataUpdatedAt,
+ refetch,
+ } = useClaudeUsage(isCliVerified);
+
+ // Check if data is stale (older than 2 minutes)
const isStale = useMemo(() => {
- return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
- }, [claudeUsageLastUpdated]);
-
- const fetchUsage = useCallback(
- async (isAutoRefresh = false) => {
- if (!isAutoRefresh) setLoading(true);
- setError(null);
- try {
- const api = getElectronAPI();
- if (!api.claude) {
- setError({
- code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
- message: 'Claude API bridge not available',
- });
- return;
- }
- const data = await api.claude.getUsage();
- if ('error' in data) {
- // Detect trust prompt error
- const isTrustPrompt =
- data.error === 'Trust prompt pending' ||
- (data.message && data.message.includes('folder permission'));
- setError({
- code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
- message: data.message || data.error,
- });
- return;
- }
- setClaudeUsage(data);
- } catch (err) {
- setError({
- code: ERROR_CODES.UNKNOWN,
- message: err instanceof Error ? err.message : 'Failed to fetch usage',
- });
- } finally {
- if (!isAutoRefresh) setLoading(false);
- }
- },
- [setClaudeUsage]
- );
-
- // Auto-fetch on mount if data is stale (only if CLI is verified)
- useEffect(() => {
- if (isStale && isCliVerified) {
- fetchUsage(true);
- }
- }, [isStale, isCliVerified, fetchUsage]);
-
- useEffect(() => {
- // Skip if CLI is not verified
- if (!isCliVerified) return;
-
- // Initial fetch when opened
- if (open) {
- if (!claudeUsage || isStale) {
- fetchUsage();
- }
- }
-
- // Auto-refresh interval (only when open)
- let intervalId: NodeJS.Timeout | null = null;
- if (open) {
- intervalId = setInterval(() => {
- fetchUsage(true);
- }, REFRESH_INTERVAL_SECONDS * 1000);
- }
-
- return () => {
- if (intervalId) clearInterval(intervalId);
- };
- }, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
+ return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
+ }, [dataUpdatedAt]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -143,7 +69,6 @@ export function ClaudeUsagePopover() {
isPrimary?: boolean;
stale?: boolean;
}) => {
- // Check if percentage is valid (not NaN, not undefined, is a finite number)
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
@@ -244,10 +169,10 @@ export function ClaudeUsagePopover() {
!loading && fetchUsage(false)}
+ className={cn('h-6 w-6', isFetching && 'opacity-80')}
+ onClick={() => !isFetching && refetch()}
>
-
+
)}
@@ -258,28 +183,18 @@ export function ClaudeUsagePopover() {
-
{error.message}
+
+ {error instanceof Error ? error.message : 'Failed to fetch usage'}
+
- {error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
- 'Ensure the Electron bridge is running or restart the app'
- ) : error.code === ERROR_CODES.TRUST_PROMPT ? (
- <>
- Run claude in your
- terminal and approve access to continue
- >
- ) : (
- <>
- Make sure Claude CLI is installed and authenticated via{' '}
- claude login
- >
- )}
+ Make sure Claude CLI is installed and authenticated via{' '}
+ claude login
- ) : !claudeUsage ? (
- // Loading state
+ ) : isLoading || !claudeUsage ? (
-
+
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..430ccdfa 100644
--- a/apps/ui/src/components/codex-usage-popover.tsx
+++ b/apps/ui/src/components/codex-usage-popover.tsx
@@ -1,11 +1,11 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useMemo } 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';
import { useSetupStore } from '@/store/setup-store';
+import { useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -22,9 +22,6 @@ type UsageError = {
message: string;
};
-// Fixed refresh interval (45 seconds)
-const REFRESH_INTERVAL_SECONDS = 45;
-
// Helper to format reset time
function formatResetTime(unixTimestamp: number): string {
const date = new Date(unixTimestamp * 1000);
@@ -62,95 +59,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string
}
export function CodexUsagePopover() {
- const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
// Check if Codex is authenticated
const isCodexAuthenticated = codexAuthStatus?.authenticated;
+ // Use React Query for data fetching with automatic polling
+ const {
+ data: codexUsage,
+ isLoading,
+ isFetching,
+ error: queryError,
+ dataUpdatedAt,
+ refetch,
+ } = useCodexUsage(isCodexAuthenticated);
+
// Check if data is stale (older than 2 minutes)
const isStale = useMemo(() => {
- return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
- }, [codexUsageLastUpdated]);
+ return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
+ }, [dataUpdatedAt]);
- const fetchUsage = useCallback(
- async (isAutoRefresh = false) => {
- if (!isAutoRefresh) setLoading(true);
- setError(null);
- try {
- const api = getElectronAPI();
- if (!api.codex) {
- setError({
- code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
- message: 'Codex API bridge not available',
- });
- return;
- }
- const data = await api.codex.getUsage();
- if ('error' in data) {
- // Check if it's the "not available" error
- if (
- data.message?.includes('not available') ||
- data.message?.includes('does not provide')
- ) {
- setError({
- code: ERROR_CODES.NOT_AVAILABLE,
- message: data.message || data.error,
- });
- } else {
- setError({
- code: ERROR_CODES.AUTH_ERROR,
- message: data.message || data.error,
- });
- }
- return;
- }
- setCodexUsage(data);
- } catch (err) {
- setError({
- code: ERROR_CODES.UNKNOWN,
- message: err instanceof Error ? err.message : 'Failed to fetch usage',
- });
- } finally {
- if (!isAutoRefresh) setLoading(false);
- }
- },
- [setCodexUsage]
- );
-
- // Auto-fetch on mount if data is stale (only if authenticated)
- useEffect(() => {
- if (isStale && isCodexAuthenticated) {
- fetchUsage(true);
+ // Convert query error to UsageError format for backward compatibility
+ const error = useMemo((): UsageError | null => {
+ if (!queryError) return null;
+ const message = queryError instanceof Error ? queryError.message : String(queryError);
+ if (message.includes('not available') || message.includes('does not provide')) {
+ return { code: ERROR_CODES.NOT_AVAILABLE, message };
}
- }, [isStale, isCodexAuthenticated, fetchUsage]);
-
- useEffect(() => {
- // Skip if not authenticated
- if (!isCodexAuthenticated) return;
-
- // Initial fetch when opened
- if (open) {
- if (!codexUsage || isStale) {
- fetchUsage();
- }
+ if (message.includes('bridge') || message.includes('API')) {
+ return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
-
- // Auto-refresh interval (only when open)
- let intervalId: NodeJS.Timeout | null = null;
- if (open) {
- intervalId = setInterval(() => {
- fetchUsage(true);
- }, REFRESH_INTERVAL_SECONDS * 1000);
- }
-
- return () => {
- if (intervalId) clearInterval(intervalId);
- };
- }, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
+ return { code: ERROR_CODES.AUTH_ERROR, message };
+ }, [queryError]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -288,10 +229,10 @@ export function CodexUsagePopover() {
!loading && fetchUsage(false)}
+ className={cn('h-6 w-6', isFetching && 'opacity-80')}
+ onClick={() => !isFetching && refetch()}
>
-
+
)}
@@ -333,7 +274,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..208d2059 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 {
@@ -44,6 +45,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
setCardBorderOpacity,
setHideScrollbar,
clearBoardBackground,
+ persistSettings,
+ getCurrentSettings,
} = useBoardBackgroundSettings();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
@@ -54,12 +57,31 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
- const cardOpacity = backgroundSettings.cardOpacity;
- const columnOpacity = backgroundSettings.columnOpacity;
+ // Local state for sliders during dragging (avoids store updates during drag)
+ const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
+ const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
+ const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
+ backgroundSettings.cardBorderOpacity
+ );
+ const [isDragging, setIsDragging] = useState(false);
+
+ // Sync local state with store when not dragging (e.g., on modal open or external changes)
+ useEffect(() => {
+ if (!isDragging) {
+ setLocalCardOpacity(backgroundSettings.cardOpacity);
+ setLocalColumnOpacity(backgroundSettings.columnOpacity);
+ setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
+ }
+ }, [
+ isDragging,
+ backgroundSettings.cardOpacity,
+ backgroundSettings.columnOpacity,
+ backgroundSettings.cardBorderOpacity,
+ ]);
+
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
- const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
const hideScrollbar = backgroundSettings.hideScrollbar;
const imageVersion = backgroundSettings.imageVersion;
@@ -197,21 +219,40 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
}
}, [currentProject, clearBoardBackground]);
- // Live update opacity when sliders change (with persistence)
- const handleCardOpacityChange = useCallback(
- async (value: number[]) => {
+ // Live update local state during drag (modal-only, no store update)
+ const handleCardOpacityChange = useCallback((value: number[]) => {
+ setIsDragging(true);
+ setLocalCardOpacity(value[0]);
+ }, []);
+
+ // Update store and persist when slider is released
+ const handleCardOpacityCommit = useCallback(
+ (value: number[]) => {
if (!currentProject) return;
- await setCardOpacity(currentProject.path, value[0]);
+ setIsDragging(false);
+ setCardOpacity(currentProject.path, value[0]);
+ const current = getCurrentSettings(currentProject.path);
+ persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
},
- [currentProject, setCardOpacity]
+ [currentProject, setCardOpacity, getCurrentSettings, persistSettings]
);
- const handleColumnOpacityChange = useCallback(
- async (value: number[]) => {
+ // Live update local state during drag (modal-only, no store update)
+ const handleColumnOpacityChange = useCallback((value: number[]) => {
+ setIsDragging(true);
+ setLocalColumnOpacity(value[0]);
+ }, []);
+
+ // Update store and persist when slider is released
+ const handleColumnOpacityCommit = useCallback(
+ (value: number[]) => {
if (!currentProject) return;
- await setColumnOpacity(currentProject.path, value[0]);
+ setIsDragging(false);
+ setColumnOpacity(currentProject.path, value[0]);
+ const current = getCurrentSettings(currentProject.path);
+ persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
},
- [currentProject, setColumnOpacity]
+ [currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
);
const handleColumnBorderToggle = useCallback(
@@ -238,12 +279,22 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
[currentProject, setCardBorderEnabled]
);
- const handleCardBorderOpacityChange = useCallback(
- async (value: number[]) => {
+ // Live update local state during drag (modal-only, no store update)
+ const handleCardBorderOpacityChange = useCallback((value: number[]) => {
+ setIsDragging(true);
+ setLocalCardBorderOpacity(value[0]);
+ }, []);
+
+ // Update store and persist when slider is released
+ const handleCardBorderOpacityCommit = useCallback(
+ (value: number[]) => {
if (!currentProject) return;
- await setCardBorderOpacity(currentProject.path, value[0]);
+ setIsDragging(false);
+ setCardBorderOpacity(currentProject.path, value[0]);
+ const current = getCurrentSettings(currentProject.path);
+ persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
},
- [currentProject, setCardBorderOpacity]
+ [currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
);
const handleHideScrollbarToggle = useCallback(
@@ -313,7 +364,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
/>
{isProcessing && (
-
+
)}
@@ -353,7 +404,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
)}
>
{isProcessing ? (
-
+
) : (
)}
@@ -377,11 +428,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
Card Opacity
- {cardOpacity}%
+ {localCardOpacity}%
Column Opacity
- {columnOpacity}%
+ {localColumnOpacity}%
Card Border Opacity
- {cardBorderOpacity}%
+ {localCardBorderOpacity}%
{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..9a54f7ab 100644
--- a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx
+++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx
@@ -1,4 +1,3 @@
-import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -8,8 +7,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
-import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
-import { getHttpApiClient } from '@/lib/http-api-client';
+import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory {
name: string;
@@ -23,41 +23,15 @@ interface WorkspacePickerModalProps {
}
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
- const [isLoading, setIsLoading] = useState(false);
- const [directories, setDirectories] = useState([]);
- const [error, setError] = useState(null);
-
- const loadDirectories = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const client = getHttpApiClient();
- const result = await client.workspace.getDirectories();
-
- if (result.success && result.directories) {
- setDirectories(result.directories);
- } else {
- setError(result.error || 'Failed to load directories');
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load directories');
- } finally {
- setIsLoading(false);
- }
- }, []);
-
- // Load directories when modal opens
- useEffect(() => {
- if (open) {
- loadDirectories();
- }
- }, [open, loadDirectories]);
+ // React Query hook - only fetch when modal is open
+ const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
const handleSelect = (dir: WorkspaceDirectory) => {
onSelect(dir.path, dir.name);
};
+ const errorMessage = error instanceof Error ? error.message : null;
+
return (
@@ -74,24 +48,24 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
{isLoading && (
)}
- {error && !isLoading && (
+ {errorMessage && !isLoading && (
-
{error}
-
+ {errorMessage}
+ refetch()} className="mt-2">
Try Again
)}
- {!isLoading && !error && directories.length === 0 && (
+ {!isLoading && !errorMessage && directories.length === 0 && (
@@ -102,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
)}
- {!isLoading && !error && directories.length > 0 && (
+ {!isLoading && !errorMessage && directories.length > 0 && (
{directories.map((dir) => (
;
+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-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx
index 2e913e83..0df4ab8c 100644
--- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx
+++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx
@@ -456,7 +456,7 @@ export function ProjectContextMenu({
{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..ea73f63a 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,22 +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) {
+export function useProjectCreation({ upsertAndSetCurrentProject }: UseProjectCreationProps) {
// Modal state
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
@@ -67,14 +58,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 +77,7 @@ export function useProjectCreation({
throw error;
}
},
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
+ [upsertAndSetCurrentProject]
);
/**
@@ -169,14 +154,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 +173,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
- [trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
+ [upsertAndSetCurrentProject]
);
/**
@@ -244,14 +223,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 +242,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..b539eb84 100644
--- a/apps/ui/src/components/session-manager.tsx
+++ b/apps/ui/src/components/session-manager.tsx
@@ -1,5 +1,6 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
+import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager');
@@ -16,12 +17,14 @@ 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';
import { getElectronAPI } from '@/lib/electron';
+import { useSessions } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
@@ -102,7 +105,7 @@ export function SessionManager({
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
- const [sessions, setSessions] = useState
([]);
+ const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState(null);
const [editingName, setEditingName] = useState('');
@@ -113,8 +116,14 @@ export function SessionManager({
const [sessionToDelete, setSessionToDelete] = useState(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
+ // Use React Query for sessions list - always include archived, filter client-side
+ const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
+
+ // Ref to track if we've done the initial running sessions check
+ const hasCheckedInitialRef = useRef(false);
+
// Check running state for all sessions
- const checkRunningSessions = async (sessionList: SessionListItem[]) => {
+ const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
const api = getElectronAPI();
if (!api?.agent) return;
@@ -134,26 +143,26 @@ export function SessionManager({
}
setRunningSessions(runningIds);
- };
-
- // Load sessions
- const loadSessions = async () => {
- const api = getElectronAPI();
- if (!api?.sessions) return;
-
- // Always load all sessions and filter client-side
- const result = await api.sessions.list(true);
- if (result.success && result.sessions) {
- setSessions(result.sessions);
- // Check running state for all sessions
- await checkRunningSessions(result.sessions);
- }
- };
-
- useEffect(() => {
- loadSessions();
}, []);
+ // Helper to invalidate sessions cache and refetch
+ const invalidateSessions = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
+ // Also check running state after invalidation
+ const result = await refetchSessions();
+ if (result.data) {
+ await checkRunningSessions(result.data);
+ }
+ }, [queryClient, refetchSessions, checkRunningSessions]);
+
+ // Check running state on initial load (runs only once when sessions first load)
+ useEffect(() => {
+ if (sessions.length > 0 && !hasCheckedInitialRef.current) {
+ hasCheckedInitialRef.current = true;
+ checkRunningSessions(sessions);
+ }
+ }, [sessions, checkRunningSessions]);
+
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
@@ -166,7 +175,7 @@ export function SessionManager({
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
- }, [sessions, runningSessions.size, isCurrentSessionThinking]);
+ }, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
// Create new session with random name
const handleCreateSession = async () => {
@@ -180,7 +189,7 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
- await loadSessions();
+ await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -195,7 +204,7 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
- await loadSessions();
+ await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -222,7 +231,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName('');
- await loadSessions();
+ await invalidateSessions();
}
};
@@ -241,7 +250,7 @@ export function SessionManager({
if (currentSessionId === sessionId) {
onSelectSession(null);
}
- await loadSessions();
+ await invalidateSessions();
} else {
logger.error('[SessionManager] Archive failed:', result.error);
}
@@ -261,7 +270,7 @@ export function SessionManager({
try {
const result = await api.sessions.unarchive(sessionId);
if (result.success) {
- await loadSessions();
+ await invalidateSessions();
} else {
logger.error('[SessionManager] Unarchive failed:', result.error);
}
@@ -283,7 +292,7 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId);
if (result.success) {
- await loadSessions();
+ await invalidateSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
@@ -305,7 +314,7 @@ export function SessionManager({
await api.sessions.delete(session.id);
}
- await loadSessions();
+ await invalidateSessions();
setIsDeleteAllArchivedDialogOpen(false);
};
@@ -466,7 +475,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..6a4d7e03 100644
--- a/apps/ui/src/components/ui/git-diff-panel.tsx
+++ b/apps/ui/src/components/ui/git-diff-panel.tsx
@@ -1,5 +1,4 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
-import { getElectronAPI } from '@/lib/electron';
+import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
File,
@@ -9,12 +8,13 @@ import {
FilePen,
ChevronDown,
ChevronRight,
- Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
+import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
@@ -350,56 +350,44 @@ export function GitDiffPanel({
useWorktrees = false,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
- const [files, setFiles] = useState([]);
- const [diffContent, setDiffContent] = useState('');
const [expandedFiles, setExpandedFiles] = useState>(new Set());
- const loadDiffs = useCallback(async () => {
- setIsLoading(true);
- setError(null);
- try {
- const api = getElectronAPI();
+ // Use worktree diffs hook when worktrees are enabled and panel is expanded
+ // Pass undefined for featureId when not using worktrees to disable the query
+ const {
+ data: worktreeDiffsData,
+ isLoading: isLoadingWorktree,
+ error: worktreeError,
+ refetch: refetchWorktree,
+ } = useWorktreeDiffs(
+ useWorktrees && isExpanded ? projectPath : undefined,
+ useWorktrees && isExpanded ? featureId : undefined
+ );
- // Use worktree API if worktrees are enabled, otherwise use git API for main project
- if (useWorktrees) {
- if (!api?.worktree?.getDiffs) {
- throw new Error('Worktree API not available');
- }
- const result = await api.worktree.getDiffs(projectPath, featureId);
- if (result.success) {
- setFiles(result.files || []);
- setDiffContent(result.diff || '');
- } else {
- setError(result.error || 'Failed to load diffs');
- }
- } else {
- // Use git API for main project diffs
- if (!api?.git?.getDiffs) {
- throw new Error('Git API not available');
- }
- const result = await api.git.getDiffs(projectPath);
- if (result.success) {
- setFiles(result.files || []);
- setDiffContent(result.diff || '');
- } else {
- setError(result.error || 'Failed to load diffs');
- }
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load diffs');
- } finally {
- setIsLoading(false);
- }
- }, [projectPath, featureId, useWorktrees]);
+ // Use git diffs hook when worktrees are disabled and panel is expanded
+ const {
+ data: gitDiffsData,
+ isLoading: isLoadingGit,
+ error: gitError,
+ refetch: refetchGit,
+ } = useGitDiffs(projectPath, !useWorktrees && isExpanded);
- // Load diffs when expanded
- useEffect(() => {
- if (isExpanded) {
- loadDiffs();
- }
- }, [isExpanded, loadDiffs]);
+ // Select the appropriate data based on useWorktrees prop
+ const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
+ const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
+ const queryError = useWorktrees ? worktreeError : gitError;
+
+ // Extract files and diff content from the data
+ const files: FileStatus[] = diffsData?.files ?? [];
+ const diffContent = diffsData?.diff ?? '';
+ const error = queryError
+ ? queryError instanceof Error
+ ? queryError.message
+ : 'Failed to load diffs'
+ : null;
+
+ // Refetch function
+ const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
@@ -484,7 +472,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..984c9a2a 100644
--- a/apps/ui/src/components/ui/provider-icon.tsx
+++ b/apps/ui/src/components/ui/provider-icon.tsx
@@ -523,6 +523,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
}
}
+ // Check for ClaudeCompatibleProvider model patterns (GLM, MiniMax, etc.)
+ // These are model IDs like "GLM-4.5-Air", "GLM-4.7", "MiniMax-M2.1"
+ if (modelStr.includes('glm')) {
+ return 'glm';
+ }
+ if (modelStr.includes('minimax')) {
+ return 'minimax';
+ }
+
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -536,7 +545,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/skeleton.tsx b/apps/ui/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..0efc029a
--- /dev/null
+++ b/apps/ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,18 @@
+/**
+ * Skeleton Components
+ *
+ * Loading placeholder components for content that's being fetched.
+ */
+
+import { cn } from '@/lib/utils';
+
+interface SkeletonPulseProps {
+ className?: string;
+}
+
+/**
+ * Pulsing skeleton placeholder for loading states
+ */
+export function SkeletonPulse({ className }: SkeletonPulseProps) {
+ return
;
+}
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..216e4e66 100644
--- a/apps/ui/src/components/usage-popover.tsx
+++ b/apps/ui/src/components/usage-popover.tsx
@@ -1,13 +1,13 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useEffect, useMemo } from 'react';
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';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
+import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -60,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
}
export function UsagePopover() {
- const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
- const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
- const [claudeLoading, setClaudeLoading] = useState(false);
- const [codexLoading, setCodexLoading] = useState(false);
- const [claudeError, setClaudeError] = useState(null);
- const [codexError, setCodexError] = useState(null);
// Check authentication status
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
+ // Use React Query hooks for usage data
+ // Only enable polling when popover is open AND the tab is active
+ const {
+ data: claudeUsage,
+ isLoading: claudeLoading,
+ error: claudeQueryError,
+ dataUpdatedAt: claudeUsageLastUpdated,
+ refetch: refetchClaude,
+ } = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated);
+
+ const {
+ data: codexUsage,
+ isLoading: codexLoading,
+ error: codexQueryError,
+ dataUpdatedAt: codexUsageLastUpdated,
+ refetch: refetchCodex,
+ } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
+
+ // Parse errors into structured format
+ const claudeError = useMemo((): UsageError | null => {
+ if (!claudeQueryError) return null;
+ const message =
+ claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError);
+ // Detect trust prompt error
+ const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission');
+ if (isTrustPrompt) {
+ return { code: ERROR_CODES.TRUST_PROMPT, message };
+ }
+ if (message.includes('API bridge')) {
+ return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
+ }
+ return { code: ERROR_CODES.AUTH_ERROR, message };
+ }, [claudeQueryError]);
+
+ const codexError = useMemo((): UsageError | null => {
+ if (!codexQueryError) return null;
+ const message =
+ codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError);
+ if (message.includes('not available') || message.includes('does not provide')) {
+ return { code: ERROR_CODES.NOT_AVAILABLE, message };
+ }
+ if (message.includes('API bridge')) {
+ return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
+ }
+ return { code: ERROR_CODES.AUTH_ERROR, message };
+ }, [codexQueryError]);
+
// Determine which tab to show by default
useEffect(() => {
if (isClaudeAuthenticated) {
@@ -94,137 +135,9 @@ export function UsagePopover() {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
- const fetchClaudeUsage = useCallback(
- async (isAutoRefresh = false) => {
- if (!isAutoRefresh) setClaudeLoading(true);
- setClaudeError(null);
- try {
- const api = getElectronAPI();
- if (!api.claude) {
- setClaudeError({
- code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
- message: 'Claude API bridge not available',
- });
- return;
- }
- const data = await api.claude.getUsage();
- if ('error' in data) {
- // Detect trust prompt error
- const isTrustPrompt =
- data.error === 'Trust prompt pending' ||
- (data.message && data.message.includes('folder permission'));
- setClaudeError({
- code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
- message: data.message || data.error,
- });
- return;
- }
- setClaudeUsage(data);
- } catch (err) {
- setClaudeError({
- code: ERROR_CODES.UNKNOWN,
- message: err instanceof Error ? err.message : 'Failed to fetch usage',
- });
- } finally {
- if (!isAutoRefresh) setClaudeLoading(false);
- }
- },
- [setClaudeUsage]
- );
-
- const fetchCodexUsage = useCallback(
- async (isAutoRefresh = false) => {
- if (!isAutoRefresh) setCodexLoading(true);
- setCodexError(null);
- try {
- const api = getElectronAPI();
- if (!api.codex) {
- setCodexError({
- code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
- message: 'Codex API bridge not available',
- });
- return;
- }
- const data = await api.codex.getUsage();
- if ('error' in data) {
- if (
- data.message?.includes('not available') ||
- data.message?.includes('does not provide')
- ) {
- setCodexError({
- code: ERROR_CODES.NOT_AVAILABLE,
- message: data.message || data.error,
- });
- } else {
- setCodexError({
- code: ERROR_CODES.AUTH_ERROR,
- message: data.message || data.error,
- });
- }
- return;
- }
- setCodexUsage(data);
- } catch (err) {
- setCodexError({
- code: ERROR_CODES.UNKNOWN,
- message: err instanceof Error ? err.message : 'Failed to fetch usage',
- });
- } finally {
- if (!isAutoRefresh) setCodexLoading(false);
- }
- },
- [setCodexUsage]
- );
-
- // Auto-fetch on mount if data is stale
- useEffect(() => {
- if (isClaudeStale && isClaudeAuthenticated) {
- fetchClaudeUsage(true);
- }
- }, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
-
- useEffect(() => {
- if (isCodexStale && isCodexAuthenticated) {
- fetchCodexUsage(true);
- }
- }, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]);
-
- // Auto-refresh when popover is open
- useEffect(() => {
- if (!open) return;
-
- // Fetch based on active tab
- if (activeTab === 'claude' && isClaudeAuthenticated) {
- if (!claudeUsage || isClaudeStale) {
- fetchClaudeUsage();
- }
- const intervalId = setInterval(() => {
- fetchClaudeUsage(true);
- }, REFRESH_INTERVAL_SECONDS * 1000);
- return () => clearInterval(intervalId);
- }
-
- if (activeTab === 'codex' && isCodexAuthenticated) {
- if (!codexUsage || isCodexStale) {
- fetchCodexUsage();
- }
- const intervalId = setInterval(() => {
- fetchCodexUsage(true);
- }, REFRESH_INTERVAL_SECONDS * 1000);
- return () => clearInterval(intervalId);
- }
- }, [
- open,
- activeTab,
- claudeUsage,
- isClaudeStale,
- isClaudeAuthenticated,
- codexUsage,
- isCodexStale,
- isCodexAuthenticated,
- fetchClaudeUsage,
- fetchCodexUsage,
- ]);
+ // Refetch functions for manual refresh
+ const fetchClaudeUsage = () => refetchClaude();
+ const fetchCodexUsage = () => refetchCodex();
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -416,7 +329,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
- onClick={() => !claudeLoading && fetchClaudeUsage(false)}
+ onClick={() => !claudeLoading && fetchClaudeUsage()}
>
@@ -449,7 +362,7 @@ export function UsagePopover() {
) : !claudeUsage ? (
-
+
Loading usage data...
) : (
@@ -523,7 +436,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
- onClick={() => !codexLoading && fetchCodexUsage(false)}
+ onClick={() => !codexLoading && fetchCodexUsage()}
>
@@ -568,7 +481,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..ff1745e3 100644
--- a/apps/ui/src/components/views/analysis-view.tsx
+++ b/apps/ui/src/components/views/analysis-view.tsx
@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
+import { useQueryClient } from '@tanstack/react-query';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
@@ -14,12 +16,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');
@@ -72,6 +74,7 @@ export function AnalysisView() {
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
const [featureListGenerated, setFeatureListGenerated] = useState(false);
const [featureListError, setFeatureListError] = useState
(null);
+ const queryClient = useQueryClient();
// Recursively scan directory
const scanDirectory = useCallback(
@@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
} as any);
}
+ // Invalidate React Query cache to sync UI
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject.path),
+ });
+
setFeatureListGenerated(true);
} catch (error) {
logger.error('Failed to generate feature list:', error);
@@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
} finally {
setIsGeneratingFeatureList(false);
}
- }, [currentProject, projectAnalysis]);
+ }, [currentProject, projectAnalysis, queryClient]);
// Toggle folder expansion
const toggleFolder = (path: string) => {
@@ -742,7 +750,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
{isAnalyzing ? (
<>
-
+
Analyzing...
>
) : (
@@ -771,7 +779,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
) : isAnalyzing ? (
-
+
Scanning project files...
) : projectAnalysis ? (
@@ -850,7 +858,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
-
+
Generating...
>
) : (
@@ -903,7 +911,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..2624514a 100644
--- a/apps/ui/src/components/views/board-view.tsx
+++ b/apps/ui/src/components/views/board-view.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
+ DndContext,
PointerSensor,
useSensor,
useSensors,
@@ -34,7 +35,8 @@ 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 { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -48,19 +50,21 @@ import {
CompletedFeaturesModal,
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
+ DependencyLinkDialog,
EditFeatureDialog,
FollowUpDialog,
PlanApprovalDialog,
+ PullResolveConflictsDialog,
} from './board-view/dialogs';
+import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
-import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
-import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
+import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
@@ -79,6 +83,10 @@ import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
+import { usePipelineConfig } from '@/hooks/queries';
+import { useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/query-keys';
+import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType
['getWorktrees']> = [];
@@ -88,8 +96,8 @@ const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
- maxConcurrency,
- setMaxConcurrency,
+ maxConcurrency: legacyMaxConcurrency,
+ setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests,
specCreatingForProject,
setSpecCreatingForProject,
@@ -108,9 +116,37 @@ export function BoardView() {
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
- } = useAppStore();
- // Subscribe to pipelineConfigByProject to trigger re-renders when it changes
- const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
+ } = useAppStore(
+ useShallow((state) => ({
+ currentProject: state.currentProject,
+ maxConcurrency: state.maxConcurrency,
+ setMaxConcurrency: state.setMaxConcurrency,
+ defaultSkipTests: state.defaultSkipTests,
+ specCreatingForProject: state.specCreatingForProject,
+ setSpecCreatingForProject: state.setSpecCreatingForProject,
+ pendingPlanApproval: state.pendingPlanApproval,
+ setPendingPlanApproval: state.setPendingPlanApproval,
+ updateFeature: state.updateFeature,
+ getCurrentWorktree: state.getCurrentWorktree,
+ setCurrentWorktree: state.setCurrentWorktree,
+ getWorktrees: state.getWorktrees,
+ setWorktrees: state.setWorktrees,
+ useWorktrees: state.useWorktrees,
+ enableDependencyBlocking: state.enableDependencyBlocking,
+ skipVerificationInAutoMode: state.skipVerificationInAutoMode,
+ planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
+ addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
+ isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
+ getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
+ setPipelineConfig: state.setPipelineConfig,
+ }))
+ );
+ // Fetch pipeline config via React Query
+ const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
+ const queryClient = useQueryClient();
+
+ // Subscribe to auto mode events for React Query cache invalidation
+ useAutoModeQueryInvalidation(currentProject?.path);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
@@ -149,7 +185,7 @@ export function BoardView() {
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
- const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
+ const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
@@ -261,11 +297,6 @@ export function BoardView() {
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
- // Auto mode hook
- const autoMode = useAutoMode();
- // Get runningTasks from the hook (scoped to current project)
- const runningAutoTasks = autoMode.runningTasks;
-
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
@@ -331,10 +362,22 @@ export function BoardView() {
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
- // Custom collision detection that prioritizes columns over cards
+ // Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
const collisionDetectionStrategy = useCallback((args: any) => {
- // First, check if pointer is within a column
const pointerCollisions = pointerWithin(args);
+
+ // Priority 1: Specific drop targets (cards for dependency links, worktrees)
+ // These need to be detected even if they are inside a column
+ const specificTargetCollisions = pointerCollisions.filter((collision: any) => {
+ const id = String(collision.id);
+ return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
+ });
+
+ if (specificTargetCollisions.length > 0) {
+ return specificTargetCollisions;
+ }
+
+ // Priority 2: Columns
const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id)
);
@@ -344,7 +387,7 @@ export function BoardView() {
return columnCollisions;
}
- // Otherwise, use rectangle intersection for cards
+ // Priority 3: Fallback to rectangle intersection
return rectIntersection(args);
}, []);
@@ -374,14 +417,6 @@ export function BoardView() {
[hookFeatures, updateFeature, persistFeatureUpdate]
);
- // Get in-progress features for keyboard shortcuts (needed before actions hook)
- const inProgressFeaturesForShortcuts = useMemo(() => {
- return hookFeatures.filter((f) => {
- const isRunning = runningAutoTasks.includes(f.id);
- return isRunning || f.status === 'in_progress';
- });
- }, [hookFeatures, runningAutoTasks]);
-
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
@@ -407,6 +442,16 @@ export function BoardView() {
}
}, [worktrees, currentWorktreePath]);
+ // Auto mode hook - pass current worktree to get worktree-specific state
+ // Must be after selectedWorktree is defined
+ const autoMode = useAutoMode(selectedWorktree ?? undefined);
+ // Get runningTasks from the hook (scoped to current project/worktree)
+ const runningAutoTasks = autoMode.runningTasks;
+ // Get worktree-specific maxConcurrency from the hook
+ const maxConcurrency = autoMode.maxConcurrency;
+ // Get worktree-specific setter
+ const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
+
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -415,6 +460,15 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
+ // Get in-progress features for keyboard shortcuts (needed before actions hook)
+ // Must be after runningAutoTasks is defined
+ const inProgressFeaturesForShortcuts = useMemo(() => {
+ return hookFeatures.filter((f) => {
+ const isRunning = runningAutoTasks.includes(f.id);
+ return isRunning || f.status === 'in_progress';
+ });
+ }, [hookFeatures, runningAutoTasks]);
+
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
@@ -512,14 +566,14 @@ export function BoardView() {
try {
// Determine final branch name based on work mode:
- // - 'current': Empty string to clear branch assignment (work on main/current branch)
+ // - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
- // Empty string clears the branch assignment, moving features to main/current branch
- finalBranchName = '';
+ // If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
+ finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -582,10 +636,8 @@ export function BoardView() {
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
if (result.success) {
- // Update local state
- featureIds.forEach((featureId) => {
- updateFeature(featureId, finalUpdates);
- });
+ // Invalidate React Query cache to refetch features with server-updated values
+ loadFeatures();
toast.success(`Updated ${result.updatedCount} features`);
exitSelectionMode();
} else {
@@ -601,10 +653,11 @@ export function BoardView() {
[
currentProject,
selectedFeatureIds,
- updateFeature,
+ loadFeatures,
exitSelectionMode,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
+ currentWorktreeBranch,
setWorktreeRefreshKey,
]
);
@@ -728,10 +781,8 @@ export function BoardView() {
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
if (result.success) {
- // Update local state for all features
- featureIds.forEach((featureId) => {
- updateFeature(featureId, updates);
- });
+ // Invalidate React Query cache to refetch features with server-updated values
+ loadFeatures();
toast.success(`Verified ${result.updatedCount} features`);
exitSelectionMode();
} else {
@@ -743,7 +794,7 @@ export function BoardView() {
logger.error('Bulk verify failed:', error);
toast.error('Failed to verify features');
}
- }, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
+ }, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
@@ -790,10 +841,15 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
- // Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
- const handleResolveConflicts = useCallback(
- async (worktree: WorktreeInfo) => {
- const remoteBranch = `origin/${worktree.branch}`;
+ // Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
+ const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
+ setSelectedWorktreeForAction(worktree);
+ setShowPullResolveConflictsDialog(true);
+ }, []);
+
+ // Handler called when user confirms the pull & resolve conflicts dialog
+ const handleConfirmResolveConflicts = useCallback(
+ async (worktree: WorktreeInfo, remoteBranch: string) => {
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
// Create the feature
@@ -833,6 +889,48 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
+ // Handler called when merge fails due to conflicts and user wants to create a feature to resolve them
+ const handleCreateMergeConflictResolutionFeature = useCallback(
+ async (conflictInfo: MergeConflictInfo) => {
+ const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`;
+
+ // Create the feature
+ const featureData = {
+ title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`,
+ category: 'Maintenance',
+ description,
+ images: [],
+ imagePaths: [],
+ skipTests: defaultSkipTests,
+ model: 'opus' as const,
+ thinkingLevel: 'none' as const,
+ branchName: conflictInfo.targetBranch,
+ workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
+ priority: 1, // High priority for conflict resolution
+ planningMode: 'skip' as const,
+ requirePlanApproval: false,
+ };
+
+ // Capture existing feature IDs before adding
+ const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
+ await handleAddFeature(featureData);
+
+ // Find the newly created feature by looking for an ID that wasn't in the original set
+ const latestFeatures = useAppStore.getState().features;
+ const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
+
+ if (newFeature) {
+ await handleStartImplementation(newFeature);
+ } else {
+ logger.error('Could not find newly created feature to start it automatically.');
+ toast.error('Failed to auto-start feature', {
+ description: 'The feature was created but could not be started automatically.',
+ });
+ }
+ },
+ [handleAddFeature, handleStartImplementation, defaultSkipTests]
+ );
+
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters[0]) => {
@@ -856,68 +954,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 +1015,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,
@@ -1199,7 +1025,13 @@ export function BoardView() {
});
// Use drag and drop hook
- const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
+ const {
+ activeFeature,
+ handleDragStart,
+ handleDragEnd,
+ pendingDependencyLink,
+ clearPendingDependencyLink,
+ } = useBoardDragDrop({
features: hookFeatures,
currentProject,
runningAutoTasks,
@@ -1207,6 +1039,50 @@ export function BoardView() {
handleStartImplementation,
});
+ // Handle dependency link creation
+ const handleCreateDependencyLink = useCallback(
+ async (linkType: DependencyLinkType) => {
+ if (!pendingDependencyLink || !currentProject) return;
+
+ const { draggedFeature, targetFeature } = pendingDependencyLink;
+
+ if (linkType === 'parent') {
+ // Dragged feature depends on target (target is parent)
+ // Add targetFeature.id to draggedFeature.dependencies
+ const currentDeps = draggedFeature.dependencies || [];
+ if (!currentDeps.includes(targetFeature.id)) {
+ const newDeps = [...currentDeps, targetFeature.id];
+ updateFeature(draggedFeature.id, { dependencies: newDeps });
+ await persistFeatureUpdate(draggedFeature.id, { dependencies: newDeps });
+ toast.success('Dependency link created', {
+ description: `"${draggedFeature.description.slice(0, 30)}..." now depends on "${targetFeature.description.slice(0, 30)}..."`,
+ });
+ }
+ } else {
+ // Target feature depends on dragged (dragged is parent)
+ // Add draggedFeature.id to targetFeature.dependencies
+ const currentDeps = targetFeature.dependencies || [];
+ if (!currentDeps.includes(draggedFeature.id)) {
+ const newDeps = [...currentDeps, draggedFeature.id];
+ updateFeature(targetFeature.id, { dependencies: newDeps });
+ await persistFeatureUpdate(targetFeature.id, { dependencies: newDeps });
+ toast.success('Dependency link created', {
+ description: `"${targetFeature.description.slice(0, 30)}..." now depends on "${draggedFeature.description.slice(0, 30)}..."`,
+ });
+ }
+ }
+
+ clearPendingDependencyLink();
+ },
+ [
+ pendingDependencyLink,
+ currentProject,
+ updateFeature,
+ persistFeatureUpdate,
+ clearPendingDependencyLink,
+ ]
+ );
+
// Use column features hook
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
features: hookFeatures,
@@ -1218,9 +1094,7 @@ export function BoardView() {
});
// Build columnFeaturesMap for ListView
- const pipelineConfig = currentProject?.path
- ? pipelineConfigByProject[currentProject.path] || null
- : null;
+ // pipelineConfig is now from usePipelineConfig React Query hook at the top
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record = {};
@@ -1384,7 +1258,7 @@ export function BoardView() {
if (isLoading) {
return (
-
+
);
}
@@ -1399,13 +1273,31 @@ export function BoardView() {
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
- onConcurrencyChange={setMaxConcurrency}
+ onConcurrencyChange={(newMaxConcurrency) => {
+ if (currentProject && selectedWorktree) {
+ const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
+ setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
+ // Also update backend if auto mode is running
+ if (autoMode.isRunning) {
+ // Restart auto mode with new concurrency (backend will handle this)
+ autoMode.stop().then(() => {
+ autoMode.start().catch((error) => {
+ logger.error('[AutoMode] Failed to restart with new concurrency:', error);
+ });
+ });
+ }
+ }
+ }}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
- 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)}
@@ -1421,133 +1313,148 @@ export function BoardView() {
onViewModeChange={setViewMode}
/>
- {/* Worktree Panel - conditionally rendered based on visibility setting */}
- {(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
- setShowCreateWorktreeDialog(true)}
- onDeleteWorktree={(worktree) => {
- setSelectedWorktreeForAction(worktree);
- setShowDeleteWorktreeDialog(true);
- }}
- onCommit={(worktree) => {
- setSelectedWorktreeForAction(worktree);
- setShowCommitWorktreeDialog(true);
- }}
- onCreatePR={(worktree) => {
- setSelectedWorktreeForAction(worktree);
- setShowCreatePRDialog(true);
- }}
- onCreateBranch={(worktree) => {
- setSelectedWorktreeForAction(worktree);
- setShowCreateBranchDialog(true);
- }}
- onAddressPRComments={handleAddressPRComments}
- onResolveConflicts={handleResolveConflicts}
- onMerge={(worktree) => {
- setSelectedWorktreeForAction(worktree);
- setShowMergeWorktreeDialog(true);
- }}
- onRemovedWorktrees={handleRemovedWorktrees}
- runningFeatureIds={runningAutoTasks}
- branchCardCounts={branchCardCounts}
- features={hookFeatures.map((f) => ({
- id: f.id,
- branchName: f.branchName,
- }))}
- />
- )}
-
- {/* Main Content Area */}
-
- {/* View Content - Kanban Board or List View */}
- {isListView ? (
- setEditingFeature(feature),
- onDelete: (featureId) => handleDeleteFeature(featureId),
- onViewOutput: handleViewOutput,
- onVerify: handleVerifyFeature,
- onResume: handleResumeFeature,
- onForceStop: handleForceStopFeature,
- onManualVerify: handleManualVerify,
- onFollowUp: handleOpenFollowUp,
- onImplement: handleStartImplementation,
- onComplete: handleCompleteFeature,
- onViewPlan: (feature) => setViewPlanFeature(feature),
- onApprovePlan: handleOpenApprovalDialog,
- onSpawnTask: (feature) => {
- setSpawnParentFeature(feature);
- setShowAddDialog(true);
- },
+ {/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
+
+ {/* Worktree Panel - conditionally rendered based on visibility setting */}
+ {(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
+ setShowCreateWorktreeDialog(true)}
+ onDeleteWorktree={(worktree) => {
+ setSelectedWorktreeForAction(worktree);
+ setShowDeleteWorktreeDialog(true);
}}
- runningAutoTasks={runningAutoTasks}
- pipelineConfig={pipelineConfig}
- onAddFeature={() => setShowAddDialog(true)}
- isSelectionMode={isSelectionMode}
- selectedFeatureIds={selectedFeatureIds}
- onToggleFeatureSelection={toggleFeatureSelection}
- onRowClick={(feature) => {
- if (feature.status === 'backlog') {
- setEditingFeature(feature);
- } else {
- handleViewOutput(feature);
- }
+ onCommit={(worktree) => {
+ setSelectedWorktreeForAction(worktree);
+ setShowCommitWorktreeDialog(true);
}}
- className="transition-opacity duration-200"
- />
- ) : (
- setEditingFeature(feature)}
- onDelete={(featureId) => handleDeleteFeature(featureId)}
- onViewOutput={handleViewOutput}
- onVerify={handleVerifyFeature}
- onResume={handleResumeFeature}
- onForceStop={handleForceStopFeature}
- onManualVerify={handleManualVerify}
- onMoveBackToInProgress={handleMoveBackToInProgress}
- onFollowUp={handleOpenFollowUp}
- onComplete={handleCompleteFeature}
- onImplement={handleStartImplementation}
- onViewPlan={(feature) => setViewPlanFeature(feature)}
- onApprovePlan={handleOpenApprovalDialog}
- onSpawnTask={(feature) => {
- setSpawnParentFeature(feature);
- setShowAddDialog(true);
+ onCreatePR={(worktree) => {
+ setSelectedWorktreeForAction(worktree);
+ setShowCreatePRDialog(true);
}}
- featuresWithContext={featuresWithContext}
- runningAutoTasks={runningAutoTasks}
- onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
- onAddFeature={() => setShowAddDialog(true)}
- onShowCompletedModal={() => setShowCompletedModal(true)}
- completedCount={completedFeatures.length}
- pipelineConfig={pipelineConfig}
- onOpenPipelineSettings={() => setShowPipelineSettings(true)}
- isSelectionMode={isSelectionMode}
- selectionTarget={selectionTarget}
- selectedFeatureIds={selectedFeatureIds}
- onToggleFeatureSelection={toggleFeatureSelection}
- onToggleSelectionMode={toggleSelectionMode}
- viewMode={viewMode}
- isDragging={activeFeature !== null}
- onAiSuggest={() => setShowPlanDialog(true)}
- className="transition-opacity duration-200"
+ onCreateBranch={(worktree) => {
+ setSelectedWorktreeForAction(worktree);
+ setShowCreateBranchDialog(true);
+ }}
+ onAddressPRComments={handleAddressPRComments}
+ onResolveConflicts={handleResolveConflicts}
+ onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
+ onBranchDeletedDuringMerge={(branchName) => {
+ // Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
+ hookFeatures.forEach((feature) => {
+ if (feature.branchName === branchName) {
+ // Reset the feature's branch assignment - update both local state and persist
+ const updates = {
+ branchName: null as unknown as string | undefined,
+ };
+ updateFeature(feature.id, updates);
+ persistFeatureUpdate(feature.id, updates);
+ }
+ });
+ setWorktreeRefreshKey((k) => k + 1);
+ }}
+ onRemovedWorktrees={handleRemovedWorktrees}
+ runningFeatureIds={runningAutoTasks}
+ branchCardCounts={branchCardCounts}
+ features={hookFeatures.map((f) => ({
+ id: f.id,
+ branchName: f.branchName,
+ }))}
/>
)}
-
+
+ {/* Main Content Area */}
+
+ {/* View Content - Kanban Board or List View */}
+ {isListView ? (
+ setEditingFeature(feature),
+ onDelete: (featureId) => handleDeleteFeature(featureId),
+ onViewOutput: handleViewOutput,
+ onVerify: handleVerifyFeature,
+ onResume: handleResumeFeature,
+ onForceStop: handleForceStopFeature,
+ onManualVerify: handleManualVerify,
+ onFollowUp: handleOpenFollowUp,
+ onImplement: handleStartImplementation,
+ onComplete: handleCompleteFeature,
+ onViewPlan: (feature) => setViewPlanFeature(feature),
+ onApprovePlan: handleOpenApprovalDialog,
+ onSpawnTask: (feature) => {
+ setSpawnParentFeature(feature);
+ setShowAddDialog(true);
+ },
+ }}
+ runningAutoTasks={runningAutoTasks}
+ pipelineConfig={pipelineConfig}
+ onAddFeature={() => setShowAddDialog(true)}
+ isSelectionMode={isSelectionMode}
+ selectedFeatureIds={selectedFeatureIds}
+ onToggleFeatureSelection={toggleFeatureSelection}
+ onRowClick={(feature) => {
+ if (feature.status === 'backlog') {
+ setEditingFeature(feature);
+ } else {
+ handleViewOutput(feature);
+ }
+ }}
+ className="transition-opacity duration-200"
+ />
+ ) : (
+ setEditingFeature(feature)}
+ onDelete={(featureId) => handleDeleteFeature(featureId)}
+ onViewOutput={handleViewOutput}
+ onVerify={handleVerifyFeature}
+ onResume={handleResumeFeature}
+ onForceStop={handleForceStopFeature}
+ onManualVerify={handleManualVerify}
+ onMoveBackToInProgress={handleMoveBackToInProgress}
+ onFollowUp={handleOpenFollowUp}
+ onComplete={handleCompleteFeature}
+ onImplement={handleStartImplementation}
+ onViewPlan={(feature) => setViewPlanFeature(feature)}
+ onApprovePlan={handleOpenApprovalDialog}
+ onSpawnTask={(feature) => {
+ setSpawnParentFeature(feature);
+ setShowAddDialog(true);
+ }}
+ featuresWithContext={featuresWithContext}
+ runningAutoTasks={runningAutoTasks}
+ onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
+ onAddFeature={() => setShowAddDialog(true)}
+ onShowCompletedModal={() => setShowCompletedModal(true)}
+ completedCount={completedFeatures.length}
+ pipelineConfig={pipelineConfig}
+ onOpenPipelineSettings={() => setShowPipelineSettings(true)}
+ isSelectionMode={isSelectionMode}
+ selectionTarget={selectionTarget}
+ selectedFeatureIds={selectedFeatureIds}
+ onToggleFeatureSelection={toggleFeatureSelection}
+ onToggleSelectionMode={toggleSelectionMode}
+ viewMode={viewMode}
+ isDragging={activeFeature !== null}
+ onAiSuggest={() => setShowPlanDialog(true)}
+ className="transition-opacity duration-200"
+ />
+ )}
+
+
{/* Selection Action Bar */}
{isSelectionMode && (
@@ -1641,6 +1548,15 @@ export function BoardView() {
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
+ {/* Dependency Link Dialog */}
+ !open && clearPendingDependencyLink()}
+ draggedFeature={pendingDependencyLink?.draggedFeature || null}
+ targetFeature={pendingDependencyLink?.targetFeature || null}
+ onLink={handleCreateDependencyLink}
+ />
+
{/* Edit Feature Dialog */}
{/* Archive All Verified Dialog */}
@@ -1687,6 +1604,11 @@ export function BoardView() {
if (!result.success) {
throw new Error(result.error || 'Failed to save pipeline config');
}
+ // Invalidate React Query cache to refetch updated config
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.pipeline.config(currentProject.path),
+ });
+ // Also update Zustand for backward compatibility
setPipelineConfig(currentProject.path, config);
}}
/>
@@ -1806,33 +1728,12 @@ export function BoardView() {
}}
/>
- {/* Merge Worktree Dialog */}
- f.branchName === selectedWorktreeForAction.branch).length
- : 0
- }
- onMerged={(mergedWorktree) => {
- // Reset features that were assigned to the merged worktree (by branch)
- hookFeatures.forEach((feature) => {
- if (feature.branchName === mergedWorktree.branch) {
- // Reset the feature's branch assignment - update both local state and persist
- const updates = {
- branchName: null as unknown as string | undefined,
- };
- updateFeature(feature.id, updates);
- persistFeatureUpdate(feature.id, updates);
- }
- });
-
- setWorktreeRefreshKey((k) => k + 1);
- setSelectedWorktreeForAction(null);
- }}
+ onConfirm={handleConfirmResolveConflicts}
/>
{/* Commit Worktree Dialog */}
diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx
index b5684a08..77a272c9 100644
--- a/apps/ui/src/components/views/board-view/board-header.tsx
+++ b/apps/ui/src/components/views/board-view/board-header.tsx
@@ -142,7 +142,8 @@ export function BoardHeader({
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
- onOpenAutoModeSettings={() => {}}
+ skipVerificationInAutoMode={skipVerificationInAutoMode}
+ onSkipVerificationChange={setSkipVerificationInAutoMode}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}
@@ -182,6 +183,13 @@ export function BoardHeader({
>
Auto Mode
+
+ {maxConcurrency}
+
-
+
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..9cd9d793 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
@@ -1,5 +1,4 @@
-// @ts-nocheck
-import { useEffect, useState, useMemo } from 'react';
+import { memo, useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -11,19 +10,12 @@ 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';
+import { useFeature, useAgentOutput } from '@/hooks/queries';
/**
* Formats thinking level for compact display
@@ -58,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
interface AgentInfoPanelProps {
feature: Feature;
+ projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
}
-export function AgentInfoPanel({
+export const AgentInfoPanel = memo(function AgentInfoPanel({
feature,
+ projectPath,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
- const [agentInfo, setAgentInfo] = useState(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map
>(new Map());
- // Fresh planSpec data fetched from API (store data is stale for task progress)
- const [freshPlanSpec, setFreshPlanSpec] = useState<{
- tasks?: ParsedTask[];
- tasksCompleted?: number;
- currentTaskId?: string;
- } | null>(null);
+
+ // Determine if we should poll for updates
+ const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
+ const shouldFetchData = feature.status !== 'backlog';
+
+ // Fetch fresh feature data for planSpec (store data can be stale for task progress)
+ const { data: freshFeature } = useFeature(projectPath, feature.id, {
+ enabled: shouldFetchData && !contextContent,
+ pollingInterval: shouldPoll ? 3000 : false,
+ });
+
+ // Fetch agent output for parsing
+ const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
+ enabled: shouldFetchData && !contextContent,
+ pollingInterval: shouldPoll ? 3000 : false,
+ });
+
+ // Parse agent output into agentInfo
+ const agentInfo = useMemo(() => {
+ if (contextContent) {
+ return parseAgentContext(contextContent);
+ }
+ if (agentOutputContent) {
+ return parseAgentContext(agentOutputContent);
+ }
+ return null;
+ }, [contextContent, agentOutputContent]);
+
+ // Fresh planSpec data from API (more accurate than store data for task progress)
+ const freshPlanSpec = useMemo(() => {
+ if (!freshFeature?.planSpec) return null;
+ return {
+ tasks: freshFeature.planSpec.tasks,
+ tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
+ currentTaskId: freshFeature.planSpec.currentTaskId,
+ };
+ }, [freshFeature?.planSpec]);
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
@@ -133,73 +157,6 @@ export function AgentInfoPanel({
taskStatusMap,
]);
- useEffect(() => {
- const loadContext = async () => {
- if (contextContent) {
- const info = parseAgentContext(contextContent);
- setAgentInfo(info);
- return;
- }
-
- if (feature.status === 'backlog') {
- setAgentInfo(null);
- setFreshPlanSpec(null);
- return;
- }
-
- try {
- const api = getElectronAPI();
- const currentProject = (window as any).__currentProject;
- if (!currentProject?.path) return;
-
- if (api.features) {
- // Fetch fresh feature data to get up-to-date planSpec (store data is stale)
- try {
- const featureResult = await api.features.get(currentProject.path, feature.id);
- const freshFeature: any = (featureResult as any).feature;
- if (featureResult.success && freshFeature?.planSpec) {
- setFreshPlanSpec({
- tasks: freshFeature.planSpec.tasks,
- tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
- currentTaskId: freshFeature.planSpec.currentTaskId,
- });
- }
- } catch {
- // Ignore errors fetching fresh planSpec
- }
-
- const result = await api.features.getAgentOutput(currentProject.path, feature.id);
-
- if (result.success && result.content) {
- const info = parseAgentContext(result.content);
- setAgentInfo(info);
- }
- } else {
- const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
- const result = await api.readFile(contextPath);
-
- if (result.success && result.content) {
- const info = parseAgentContext(result.content);
- setAgentInfo(info);
- }
- }
- } catch {
- console.debug('[KanbanCard] No context file for feature:', feature.id);
- }
- };
-
- loadContext();
-
- // Poll for updates when feature is in_progress (not just isCurrentAutoTask)
- // This ensures planSpec progress stays in sync
- if (isCurrentAutoTask || feature.status === 'in_progress') {
- const interval = setInterval(loadContext, 3000);
- return () => {
- clearInterval(interval);
- };
- }
- }, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
-
// Listen to WebSocket events for real-time task status updates
// This ensures the Kanban card shows the same progress as the Agent Output modal
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
@@ -338,7 +295,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
) : todo.status === 'in_progress' ? (
-
+
) : (
)}
@@ -448,4 +405,4 @@ export function AgentInfoPanel({
onOpenChange={setIsSummaryDialogOpen}
/>
);
-}
+});
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx
index 7dfa4bef..0151a798 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx
@@ -1,4 +1,5 @@
// @ts-nocheck
+import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
@@ -32,7 +33,7 @@ interface CardActionsProps {
onApprovePlan?: () => void;
}
-export function CardActions({
+export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
hasContext,
@@ -344,4 +345,4 @@ export function CardActions({
)}
);
-}
+});
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx
index 268e67be..e2673415 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx
@@ -1,10 +1,11 @@
// @ts-nocheck
-import { useEffect, useMemo, useState } from 'react';
+import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
+import { useShallow } from 'zustand/react/shallow';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -18,7 +19,7 @@ interface CardBadgesProps {
* CardBadges - Shows error badges below the card header
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
*/
-export function CardBadges({ feature }: CardBadgesProps) {
+export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) {
if (!feature.error) {
return null;
}
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
);
-}
+});
interface PriorityBadgesProps {
feature: Feature;
}
-export function PriorityBadges({ feature }: PriorityBadgesProps) {
- const { enableDependencyBlocking, features } = useAppStore();
+export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
+ const { enableDependencyBlocking, features } = useAppStore(
+ useShallow((state) => ({
+ enableDependencyBlocking: state.enableDependencyBlocking,
+ features: state.features,
+ }))
+ );
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
);
-}
+});
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx
index 237c0a7e..5b2229d8 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx
@@ -1,4 +1,5 @@
// @ts-nocheck
+import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
useWorktrees: boolean;
}
-export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
+export const CardContentSections = memo(function CardContentSections({
+ feature,
+ useWorktrees,
+}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
})()}
>
);
-}
+});
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..87a26cdf 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
@@ -1,5 +1,5 @@
// @ts-nocheck
-import { useState } from 'react';
+import { memo, useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -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';
@@ -37,7 +37,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
}
-export function CardHeaderSection({
+export const CardHeaderSection = memo(function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
@@ -65,7 +65,7 @@ export function CardHeaderSection({
{isCurrentAutoTask && !isSelectionMode && (
-
+
{feature.startedAt && (
{feature.titleGenerating ? (
-
+
Generating title...
) : feature.title ? (
@@ -378,4 +378,4 @@ export function CardHeaderSection({
/>
);
-}
+});
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx
index ab640c21..ba1dd97e 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx
@@ -1,10 +1,11 @@
// @ts-nocheck
-import React, { memo, useLayoutEffect, useState } from 'react';
-import { useDraggable } from '@dnd-kit/core';
+import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
+import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Feature, useAppStore } from '@/store/app-store';
+import { useShallow } from 'zustand/react/shallow';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
@@ -61,6 +62,7 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
+ reduceEffects?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
+ reduceEffects = false,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
- const { useWorktrees } = useAppStore();
+ const { useWorktrees, currentProject } = useAppStore(
+ useShallow((state) => ({
+ useWorktrees: state.useWorktrees,
+ currentProject: state.currentProject,
+ }))
+ );
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -115,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
(feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
+ feature.status.startsWith('pipeline_') ||
(feature.status === 'in_progress' && !isCurrentAutoTask));
- const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
+ const {
+ attributes,
+ listeners,
+ setNodeRef: setDraggableRef,
+ isDragging,
+ } = useDraggable({
id: feature.id,
disabled: !isDraggable || isOverlay || isSelectionMode,
});
+ // Make the card a drop target for creating dependency links
+ // Only backlog cards can be link targets (to avoid complexity with running features)
+ const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
+ const { setNodeRef: setDroppableRef, isOver } = useDroppable({
+ id: `card-drop-${feature.id}`,
+ disabled: !isDroppable,
+ data: {
+ type: 'card',
+ featureId: feature.id,
+ },
+ });
+
+ // Combine refs for both draggable and droppable
+ const setNodeRef = useCallback(
+ (node: HTMLElement | null) => {
+ setDraggableRef(node);
+ setDroppableRef(node);
+ },
+ [setDraggableRef, setDroppableRef]
+ );
+
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
};
@@ -133,16 +168,23 @@ export const KanbanCard = memo(function KanbanCard({
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable, isSelectable),
- isOverlay && isLifted && 'scale-105 rotate-1 z-50'
+ isOverlay && isLifted && 'scale-105 rotate-1 z-50',
+ // Visual feedback when another card is being dragged over this one
+ isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
);
const isInteractive = !isDragging && !isOverlay;
const hasError = feature.error && !isCurrentAutoTask;
const innerCardClasses = cn(
- 'kanban-card-content h-full relative shadow-sm',
+ 'kanban-card-content h-full relative',
+ reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out',
- isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
+ // Disable hover translate for in-progress cards to prevent gap showing gradient
+ isInteractive &&
+ !reduceEffects &&
+ !isCurrentAutoTask &&
+ 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
@@ -215,6 +257,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Agent Info Panel */}
;
+ onScroll?: (event: UIEvent) => void;
+ contentClassName?: string;
+ contentStyle?: CSSProperties;
+ disableItemSpacing?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
showBorder = true,
hideScrollbar = false,
width,
+ contentRef,
+ onScroll,
+ contentClassName,
+ contentStyle,
+ disableItemSpacing = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */}
{children}
diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx
index cca4e474..aad969b6 100644
--- a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx
+++ b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx
@@ -23,7 +23,6 @@ interface ColumnDef {
/**
* Default column definitions for the list view
- * Only showing title column with full width for a cleaner, more spacious layout
*/
export const LIST_COLUMNS: ColumnDef[] = [
{
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
minWidth: 'min-w-0',
align: 'left',
},
+ {
+ id: 'priority',
+ label: 'Priority',
+ sortable: true,
+ width: 'w-20',
+ minWidth: 'min-w-[60px]',
+ align: 'center',
+ },
];
export interface ListHeaderProps {
@@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width,
column.minWidth,
+ column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
isSorted && 'text-foreground',
@@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width,
column.minWidth,
+ column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className
diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx
index f3877906..a3d10eb7 100644
--- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx
+++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx
@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
+ {/* Priority column */}
+
+ {feature.priority ? (
+
+ {feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
+
+ ) : (
+ -
+ )}
+
+
{/* Actions column */}
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..6db3df66 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';
@@ -14,6 +15,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { Markdown } from '@/components/ui/markdown';
import { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
+import { useAgentOutput } from '@/hooks/queries';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -27,6 +29,8 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
+ /** Branch name for the feature worktree - used when viewing changes */
+ branchName?: string;
}
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
@@ -39,12 +43,33 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
+ branchName,
}: AgentOutputModalProps) {
const isBacklogPlan = featureId.startsWith('backlog-plan:');
- const [output, setOutput] = useState('');
- const [isLoading, setIsLoading] = useState(true);
+
+ // Resolve project path - prefer prop, fallback to window.__currentProject
+ const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
+
+ // Track additional content from WebSocket events (appended to query data)
+ const [streamedContent, setStreamedContent] = useState('');
const [viewMode, setViewMode] = useState(null);
- const [projectPath, setProjectPath] = useState('');
+
+ // Use React Query for initial output loading
+ const { data: initialOutput = '', isLoading } = useAgentOutput(
+ resolvedProjectPath,
+ featureId,
+ open && !!resolvedProjectPath
+ );
+
+ // Reset streamed content when modal opens or featureId changes
+ useEffect(() => {
+ if (open) {
+ setStreamedContent('');
+ }
+ }, [open, featureId]);
+
+ // Combine initial output from query with streamed content from WebSocket
+ const output = initialOutput + streamedContent;
// Extract summary from output
const summary = useMemo(() => extractSummary(output), [output]);
@@ -53,7 +78,6 @@ export function AgentOutputModal({
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef(null);
const autoScrollRef = useRef(true);
- const projectPathRef = useRef('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -63,55 +87,6 @@ export function AgentOutputModal({
}
}, [output]);
- // Load existing output from file
- useEffect(() => {
- if (!open) return;
-
- const loadOutput = async () => {
- const api = getElectronAPI();
- if (!api) return;
-
- setIsLoading(true);
-
- try {
- // Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
- const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
- if (!resolvedProjectPath) {
- setIsLoading(false);
- return;
- }
-
- projectPathRef.current = resolvedProjectPath;
- setProjectPath(resolvedProjectPath);
-
- if (isBacklogPlan) {
- setOutput('');
- return;
- }
-
- // Use features API to get agent output
- if (api.features) {
- const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
-
- if (result.success) {
- setOutput(result.content || '');
- } else {
- setOutput('');
- }
- } else {
- setOutput('');
- }
- } catch (error) {
- console.error('Failed to load output:', error);
- setOutput('');
- } finally {
- setIsLoading(false);
- }
- };
-
- loadOutput();
- }, [open, featureId, projectPathProp, isBacklogPlan]);
-
// Listen to auto mode events and update output
useEffect(() => {
if (!open) return;
@@ -270,8 +245,8 @@ export function AgentOutputModal({
}
if (newContent) {
- // Only update local state - server is the single source of truth for file writes
- setOutput((prev) => prev + newContent);
+ // Append new content from WebSocket to streamed content
+ setStreamedContent((prev) => prev + newContent);
}
});
@@ -353,7 +328,7 @@ export function AgentOutputModal({
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
-
+
)}
Agent Output
@@ -422,24 +397,24 @@ export function AgentOutputModal({
{!isBacklogPlan && (
)}
{effectiveViewMode === 'changes' ? (
- {projectPath ? (
+ {resolvedProjectPath ? (
) : (
-
+
Loading...
)}
@@ -457,7 +432,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..f072f733 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
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -13,9 +13,11 @@ 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';
+import { useWorktreeBranches } from '@/hooks/queries';
interface WorktreeInfo {
path: string;
@@ -53,12 +55,21 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState(null);
const [browserUrl, setBrowserUrl] = useState(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
- // Branch fetching state
- const [branches, setBranches] = useState([]);
- const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
+ // Use React Query for branch fetching - only enabled when dialog is open
+ const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
+ open ? worktree?.path : undefined,
+ true // Include remote branches for PR base branch selection
+ );
+
+ // Filter out current worktree branch from the list
+ const branches = useMemo(() => {
+ if (!branchesData?.branches) return [];
+ return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
+ }, [branchesData?.branches, worktree?.branch]);
+
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -71,44 +82,13 @@ export function CreatePRDialog({
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
- setBranches([]);
}, [defaultBaseBranch]);
- // Fetch branches for autocomplete
- const fetchBranches = useCallback(async () => {
- if (!worktree?.path) return;
-
- setIsLoadingBranches(true);
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.listBranches) {
- return;
- }
- // Fetch both local and remote branches for PR base branch selection
- const result = await api.worktree.listBranches(worktree.path, true);
- if (result.success && result.result) {
- // Extract branch names, filtering out the current worktree branch
- const branchNames = result.result.branches
- .map((b) => b.name)
- .filter((name) => name !== worktree.branch);
- setBranches(branchNames);
- }
- } catch {
- // Silently fail - branches will default to main only
- } finally {
- setIsLoadingBranches(false);
- }
- }, [worktree?.path, worktree?.branch]);
-
// Reset state when dialog opens or worktree changes
useEffect(() => {
// Reset all state on both open and close
resetState();
- if (open) {
- // Fetch fresh branches when dialog opens
- fetchBranches();
- }
- }, [open, worktree?.path, resetState, fetchBranches]);
+ }, [open, worktree?.path, resetState]);
const handleCreate = async () => {
if (!worktree) return;
@@ -405,7 +385,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/dependency-link-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx
new file mode 100644
index 00000000..152e6702
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
+import type { Feature } from '@/store/app-store';
+import { cn } from '@/lib/utils';
+
+export type DependencyLinkType = 'parent' | 'child';
+
+interface DependencyLinkDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ draggedFeature: Feature | null;
+ targetFeature: Feature | null;
+ onLink: (linkType: DependencyLinkType) => void;
+}
+
+export function DependencyLinkDialog({
+ open,
+ onOpenChange,
+ draggedFeature,
+ targetFeature,
+ onLink,
+}: DependencyLinkDialogProps) {
+ if (!draggedFeature || !targetFeature) return null;
+
+ // Check if a dependency relationship already exists
+ const draggedDependsOnTarget =
+ Array.isArray(draggedFeature.dependencies) &&
+ draggedFeature.dependencies.includes(targetFeature.id);
+ const targetDependsOnDragged =
+ Array.isArray(targetFeature.dependencies) &&
+ targetFeature.dependencies.includes(draggedFeature.id);
+ const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
+
+ return (
+
+
+
+
+
+ Link Features
+
+
+ Create a dependency relationship between these features.
+
+
+
+
+ {/* Dragged feature */}
+
+
Dragged Feature
+
+ {draggedFeature.description}
+
+
{draggedFeature.category}
+
+
+ {/* Arrow indicating direction */}
+
+
+ {/* Target feature */}
+
+
Target Feature
+
+ {targetFeature.description}
+
+
{targetFeature.category}
+
+
+ {/* Existing link warning */}
+ {existingLink && (
+
+ {draggedDependsOnTarget
+ ? 'The dragged feature already depends on the target feature.'
+ : 'The target feature already depends on the dragged feature.'}
+
+ )}
+
+
+
+ {/* Set as Parent - top */}
+ onLink('child')}
+ disabled={draggedDependsOnTarget}
+ className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
+ title={
+ draggedDependsOnTarget
+ ? 'This would create a circular dependency'
+ : 'Make target feature depend on dragged (dragged is parent)'
+ }
+ data-testid="link-as-parent"
+ >
+
+ Set as Parent
+ (target depends on this)
+
+ {/* Set as Child - middle */}
+ onLink('parent')}
+ disabled={targetDependsOnDragged}
+ className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
+ title={
+ targetDependsOnDragged
+ ? 'This would create a circular dependency'
+ : 'Make dragged feature depend on target (target is parent)'
+ }
+ data-testid="link-as-child"
+ >
+
+ Set as Child
+ (depends on target)
+
+ {/* Cancel - bottom */}
+ onOpenChange(false)} className="w-full">
+
+ Cancel
+
+
+
+
+ );
+}
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/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts
index 659f4d7e..419f1004 100644
--- a/apps/ui/src/components/views/board-view/dialogs/index.ts
+++ b/apps/ui/src/components/views/board-view/dialogs/index.ts
@@ -4,7 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
+export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
+export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
+export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
+export { PushToRemoteDialog } from './push-to-remote-dialog';
+export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
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..99612433 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,8 +126,9 @@ export function MassEditDialog({
});
// Field values
- const [model, setModel] = useState('sonnet');
+ const [model, setModel] = useState('claude-sonnet');
const [thinkingLevel, setThinkingLevel] = useState('none');
+ const [providerId, setProviderId] = useState(undefined);
const [planningMode, setPlanningMode] = useState('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
const [priority, setPriority] = useState(2);
@@ -160,8 +161,9 @@ 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);
+ setProviderId(undefined); // Features don't store providerId, but we track it after selection
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
@@ -226,10 +228,11 @@ export function MassEditDialog({
Select a specific model configuration
{
setModel(entry.model as ModelAlias);
setThinkingLevel(entry.thinkingLevel || 'none');
+ setProviderId(entry.providerId);
// Auto-enable model and thinking level for apply state
setApplyState((prev) => ({
...prev,
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..7bb1440a 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
@@ -8,57 +8,81 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
+import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
-import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
+import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
+import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
-interface WorktreeInfo {
- path: string;
- branch: string;
- isMain: boolean;
- hasChanges?: boolean;
- changedFilesCount?: number;
-}
+export type { MergeConflictInfo } from '../worktree-panel/types';
interface MergeWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
- onMerged: (mergedWorktree: WorktreeInfo) => void;
- /** Number of features assigned to this worktree's branch */
- affectedFeatureCount?: number;
+ /** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
+ onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
+ onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
-type DialogStep = 'confirm' | 'verify';
-
export function MergeWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onMerged,
- affectedFeatureCount = 0,
+ onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
- const [step, setStep] = useState('confirm');
- const [confirmText, setConfirmText] = useState('');
+ const [targetBranch, setTargetBranch] = useState('main');
+ const [availableBranches, setAvailableBranches] = useState([]);
+ const [loadingBranches, setLoadingBranches] = useState(false);
+ const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
+ const [mergeConflict, setMergeConflict] = useState(null);
+
+ // Fetch available branches when dialog opens
+ useEffect(() => {
+ if (open && worktree && projectPath) {
+ setLoadingBranches(true);
+ const api = getElectronAPI();
+ if (api?.worktree?.listBranches) {
+ api.worktree
+ .listBranches(projectPath, false)
+ .then((result) => {
+ if (result.success && result.result?.branches) {
+ // Filter out the source branch (can't merge into itself) and remote branches
+ const branches = result.result.branches
+ .filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
+ .map((b: BranchInfo) => b.name);
+ setAvailableBranches(branches);
+ }
+ })
+ .catch((err) => {
+ console.error('Failed to fetch branches:', err);
+ })
+ .finally(() => {
+ setLoadingBranches(false);
+ });
+ } else {
+ setLoadingBranches(false);
+ }
+ }
+ }, [open, worktree, projectPath]);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setIsLoading(false);
- setStep('confirm');
- setConfirmText('');
+ setTargetBranch('main');
+ setDeleteWorktreeAndBranch(false);
+ setMergeConflict(null);
}
}, [open]);
- const handleProceedToVerify = () => {
- setStep('verify');
- };
-
const handleMerge = async () => {
if (!worktree) return;
@@ -70,96 +94,151 @@ export function MergeWorktreeDialog({
return;
}
- // Pass branchName and worktreePath directly to the API
- const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
+ // Pass branchName, worktreePath, targetBranch, and options to the API
+ const result = await api.worktree.mergeFeature(
+ projectPath,
+ worktree.branch,
+ worktree.path,
+ targetBranch,
+ { deleteWorktreeAndBranch }
+ );
if (result.success) {
- toast.success('Branch merged to main', {
- description: `Branch "${worktree.branch}" has been merged and cleaned up`,
- });
- onMerged(worktree);
+ const description = deleteWorktreeAndBranch
+ ? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
+ : `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
+ toast.success(`Branch merged to ${targetBranch}`, { description });
+ onMerged(worktree, deleteWorktreeAndBranch);
onOpenChange(false);
} else {
- toast.error('Failed to merge branch', {
- description: result.error,
- });
+ // Check if the error indicates merge conflicts
+ const errorMessage = result.error || '';
+ const hasConflicts =
+ errorMessage.toLowerCase().includes('conflict') ||
+ errorMessage.toLowerCase().includes('merge failed') ||
+ errorMessage.includes('CONFLICT');
+
+ if (hasConflicts && onCreateConflictResolutionFeature) {
+ // Set merge conflict state to show the conflict resolution UI
+ setMergeConflict({
+ sourceBranch: worktree.branch,
+ targetBranch: targetBranch,
+ targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
+ });
+ toast.error('Merge conflicts detected', {
+ description: 'The merge has conflicts that need to be resolved manually.',
+ });
+ } else {
+ toast.error('Failed to merge branch', {
+ description: result.error,
+ });
+ }
}
} catch (err) {
- toast.error('Failed to merge branch', {
- description: err instanceof Error ? err.message : 'Unknown error',
- });
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
+ // Check if the error indicates merge conflicts
+ const hasConflicts =
+ errorMessage.toLowerCase().includes('conflict') ||
+ errorMessage.toLowerCase().includes('merge failed') ||
+ errorMessage.includes('CONFLICT');
+
+ if (hasConflicts && onCreateConflictResolutionFeature) {
+ setMergeConflict({
+ sourceBranch: worktree.branch,
+ targetBranch: targetBranch,
+ targetWorktreePath: projectPath,
+ });
+ toast.error('Merge conflicts detected', {
+ description: 'The merge has conflicts that need to be resolved manually.',
+ });
+ } else {
+ toast.error('Failed to merge branch', {
+ description: errorMessage,
+ });
+ }
} finally {
setIsLoading(false);
}
};
+ const handleCreateConflictResolutionFeature = () => {
+ if (mergeConflict && onCreateConflictResolutionFeature) {
+ onCreateConflictResolutionFeature(mergeConflict);
+ onOpenChange(false);
+ }
+ };
+
if (!worktree) return null;
- const confirmationWord = 'merge';
- const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
-
- // First step: Show what will happen and ask for confirmation
- if (step === 'confirm') {
+ // Show conflict resolution UI if there are merge conflicts
+ if (mergeConflict) {
return (
-
- Merge to Main
+
+ Merge Conflicts Detected
-
+
- Merge branch{' '}
- {worktree.branch} into
- main?
+ There are conflicts when merging{' '}
+
+ {mergeConflict.sourceBranch}
+ {' '}
+ into{' '}
+
+ {mergeConflict.targetBranch}
+
+ .
-
- This will:
-
- Merge the branch into the main branch
- Remove the worktree directory
- Delete the branch
-
+
+
+
+ The merge could not be completed automatically. You can create a feature task to
+ resolve the conflicts in the{' '}
+
+ {mergeConflict.targetBranch}
+ {' '}
+ branch.
+
- {worktree.hasChanges && (
-
-
-
- This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
- commit or discard them before merging.
-
-
- )}
-
- {affectedFeatureCount > 0 && (
-
-
-
- {affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
- {affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
- be unassigned after merge.
-
-
- )}
+
+
+ This will create a high-priority feature task that will:
+
+
+
+ Resolve merge conflicts in the{' '}
+
+ {mergeConflict.targetBranch}
+ {' '}
+ branch
+
+ Ensure the code compiles and tests pass
+ Complete the merge automatically
+
+
- onOpenChange(false)}>
+ setMergeConflict(null)}>
+ Back
+
+ onOpenChange(false)}>
Cancel
-
- Continue
+
+ Create Resolve Conflicts Feature
@@ -167,63 +246,97 @@ export function MergeWorktreeDialog({
);
}
- // Second step: Type confirmation
return (
-
- Confirm Merge
+
+ Merge Branch
-
-
-
- This action cannot be undone. The branch{' '}
- {worktree.branch} will be
- permanently deleted after merging.
-
-
+
+ Merge {worktree.branch}{' '}
+ into:
+
+
+ {worktree.hasChanges && (
+
+
+
+ This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
+ commit or discard them before merging.
+
+
+ )}
+
+ setDeleteWorktreeAndBranch(checked === true)}
+ />
+
+
+ Delete worktree and branch after merging
+
+
+
+ {deleteWorktreeAndBranch && (
+
+
+
+ The worktree and branch will be permanently deleted. Any features assigned to this
+ branch will be unassigned.
+
+
+ )}
+
- setStep('confirm')} disabled={isLoading}>
- Back
+ onOpenChange(false)} disabled={isLoading}>
+ Cancel
{isLoading ? (
<>
-
+
Merging...
>
) : (
<>
-
- Merge to Main
+
+ Merge
>
)}
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/dialogs/pull-resolve-conflicts-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx
new file mode 100644
index 00000000..a4bd44f4
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx
@@ -0,0 +1,303 @@
+import { useState, useEffect } from 'react';
+import { createLogger } from '@automaker/utils/logger';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { toast } from 'sonner';
+import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+
+interface WorktreeInfo {
+ path: string;
+ branch: string;
+ isMain: boolean;
+ hasChanges?: boolean;
+ changedFilesCount?: number;
+}
+
+interface RemoteBranch {
+ name: string;
+ fullRef: string;
+}
+
+interface RemoteInfo {
+ name: string;
+ url: string;
+ branches: RemoteBranch[];
+}
+
+const logger = createLogger('PullResolveConflictsDialog');
+
+interface PullResolveConflictsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ worktree: WorktreeInfo | null;
+ onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
+}
+
+export function PullResolveConflictsDialog({
+ open,
+ onOpenChange,
+ worktree,
+ onConfirm,
+}: PullResolveConflictsDialogProps) {
+ const [remotes, setRemotes] = useState([]);
+ const [selectedRemote, setSelectedRemote] = useState('');
+ const [selectedBranch, setSelectedBranch] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Fetch remotes when dialog opens
+ useEffect(() => {
+ if (open && worktree) {
+ fetchRemotes();
+ }
+ }, [open, worktree]);
+
+ // Reset state when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setSelectedRemote('');
+ setSelectedBranch('');
+ setError(null);
+ }
+ }, [open]);
+
+ // Auto-select default remote and branch when remotes are loaded
+ useEffect(() => {
+ if (remotes.length > 0 && !selectedRemote) {
+ // Default to 'origin' if available, otherwise first remote
+ const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
+ setSelectedRemote(defaultRemote.name);
+
+ // Try to select a matching branch name or default to main/master
+ if (defaultRemote.branches.length > 0 && worktree) {
+ const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
+ const mainBranch = defaultRemote.branches.find(
+ (b) => b.name === 'main' || b.name === 'master'
+ );
+ const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
+ setSelectedBranch(defaultBranch.fullRef);
+ }
+ }
+ }, [remotes, selectedRemote, worktree]);
+
+ // Update selected branch when remote changes
+ useEffect(() => {
+ if (selectedRemote && remotes.length > 0 && worktree) {
+ const remote = remotes.find((r) => r.name === selectedRemote);
+ if (remote && remote.branches.length > 0) {
+ // Try to select a matching branch name or default to main/master
+ const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
+ const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
+ const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
+ setSelectedBranch(defaultBranch.fullRef);
+ } else {
+ setSelectedBranch('');
+ }
+ }
+ }, [selectedRemote, remotes, worktree]);
+
+ const fetchRemotes = async () => {
+ if (!worktree) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.listRemotes(worktree.path);
+
+ if (result.success && result.result) {
+ setRemotes(result.result.remotes);
+ if (result.result.remotes.length === 0) {
+ setError('No remotes found in this repository');
+ }
+ } else {
+ setError(result.error || 'Failed to fetch remotes');
+ }
+ } catch (err) {
+ logger.error('Failed to fetch remotes:', err);
+ setError('Failed to fetch remotes');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRefresh = async () => {
+ if (!worktree) return;
+
+ setIsRefreshing(true);
+ setError(null);
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.listRemotes(worktree.path);
+
+ if (result.success && result.result) {
+ setRemotes(result.result.remotes);
+ toast.success('Remotes refreshed');
+ } else {
+ toast.error(result.error || 'Failed to refresh remotes');
+ }
+ } catch (err) {
+ logger.error('Failed to refresh remotes:', err);
+ toast.error('Failed to refresh remotes');
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const handleConfirm = () => {
+ if (!worktree || !selectedBranch) return;
+ onConfirm(worktree, selectedBranch);
+ onOpenChange(false);
+ };
+
+ const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
+ const branches = selectedRemoteData?.branches || [];
+
+ return (
+
+
+
+
+
+ Pull & Resolve Conflicts
+
+
+ Select a remote branch to pull from and resolve conflicts with{' '}
+
+ {worktree?.branch || 'current branch'}
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ ) : (
+
+
+
+ Remote
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+ Refresh
+
+
+
+
+
+
+
+ {remotes.map((remote) => (
+
+
+ {remote.name}
+
+ {remote.url}
+
+
+
+ ))}
+
+
+
+
+
+
Branch
+
+
+
+
+
+
+ {selectedRemote} branches
+ {branches.map((branch) => (
+
+ {branch.name}
+
+ ))}
+
+
+
+ {selectedRemote && branches.length === 0 && (
+
No branches found for this remote
+ )}
+
+
+ {selectedBranch && (
+
+
+ This will create a feature task to pull from{' '}
+ {selectedBranch} into{' '}
+ {worktree?.branch} and resolve
+ any merge conflicts.
+
+
+ )}
+
+ )}
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+
+ Pull & Resolve
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx
new file mode 100644
index 00000000..4e02b4e1
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx
@@ -0,0 +1,242 @@
+import { useState, useEffect } from 'react';
+import { createLogger } from '@automaker/utils/logger';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { toast } from 'sonner';
+import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+import type { WorktreeInfo } from '../worktree-panel/types';
+
+interface RemoteInfo {
+ name: string;
+ url: string;
+}
+
+const logger = createLogger('PushToRemoteDialog');
+
+interface PushToRemoteDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ worktree: WorktreeInfo | null;
+ onConfirm: (worktree: WorktreeInfo, remote: string) => void;
+}
+
+export function PushToRemoteDialog({
+ open,
+ onOpenChange,
+ worktree,
+ onConfirm,
+}: PushToRemoteDialogProps) {
+ const [remotes, setRemotes] = useState([]);
+ const [selectedRemote, setSelectedRemote] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Fetch remotes when dialog opens
+ useEffect(() => {
+ if (open && worktree) {
+ fetchRemotes();
+ }
+ }, [open, worktree]);
+
+ // Reset state when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setSelectedRemote('');
+ setError(null);
+ }
+ }, [open]);
+
+ // Auto-select default remote when remotes are loaded
+ useEffect(() => {
+ if (remotes.length > 0 && !selectedRemote) {
+ // Default to 'origin' if available, otherwise first remote
+ const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
+ setSelectedRemote(defaultRemote.name);
+ }
+ }, [remotes, selectedRemote]);
+
+ const fetchRemotes = async () => {
+ if (!worktree) return;
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.listRemotes(worktree.path);
+
+ if (result.success && result.result) {
+ // Extract just the remote info (name and URL), not the branches
+ const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
+ name: r.name,
+ url: r.url,
+ }));
+ setRemotes(remoteInfos);
+ if (remoteInfos.length === 0) {
+ setError('No remotes found in this repository. Please add a remote first.');
+ }
+ } else {
+ setError(result.error || 'Failed to fetch remotes');
+ }
+ } catch (err) {
+ logger.error('Failed to fetch remotes:', err);
+ setError('Failed to fetch remotes');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRefresh = async () => {
+ if (!worktree) return;
+
+ setIsRefreshing(true);
+ setError(null);
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.listRemotes(worktree.path);
+
+ if (result.success && result.result) {
+ const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
+ name: r.name,
+ url: r.url,
+ }));
+ setRemotes(remoteInfos);
+ toast.success('Remotes refreshed');
+ } else {
+ toast.error(result.error || 'Failed to refresh remotes');
+ }
+ } catch (err) {
+ logger.error('Failed to refresh remotes:', err);
+ toast.error('Failed to refresh remotes');
+ } finally {
+ setIsRefreshing(false);
+ }
+ };
+
+ const handleConfirm = () => {
+ if (!worktree || !selectedRemote) return;
+ onConfirm(worktree, selectedRemote);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ Push New Branch to Remote
+
+
+ new
+
+
+
+ Push{' '}
+
+ {worktree?.branch || 'current branch'}
+ {' '}
+ to a remote repository for the first time.
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ ) : (
+
+
+
+ Select Remote
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+ Refresh
+
+
+
+
+
+
+
+ {remotes.map((remote) => (
+
+
+ {remote.name}
+
+ {remote.url}
+
+
+
+ ))}
+
+
+
+
+ {selectedRemote && (
+
+
+ This will create a new remote branch{' '}
+
+ {selectedRemote}/{worktree?.branch}
+ {' '}
+ and set up tracking.
+
+
+ )}
+
+ )}
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+
+ Push to {selectedRemote || 'Remote'}
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx
new file mode 100644
index 00000000..1b49b23d
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx
@@ -0,0 +1,68 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { FileText } from 'lucide-react';
+import { GitDiffPanel } from '@/components/ui/git-diff-panel';
+
+interface WorktreeInfo {
+ path: string;
+ branch: string;
+ isMain: boolean;
+ hasChanges?: boolean;
+ changedFilesCount?: number;
+}
+
+interface ViewWorktreeChangesDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ worktree: WorktreeInfo | null;
+ projectPath: string;
+}
+
+export function ViewWorktreeChangesDialog({
+ open,
+ onOpenChange,
+ worktree,
+ projectPath,
+}: ViewWorktreeChangesDialogProps) {
+ if (!worktree) return null;
+
+ return (
+
+
+
+
+
+ View Changes
+
+
+ Changes in the{' '}
+ {worktree.branch} worktree.
+ {worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
+
+ ({worktree.changedFilesCount} file
+ {worktree.changedFilesCount > 1 ? 's' : ''} changed)
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx
index 5b42c646..f3c2c19d 100644
--- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx
+++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx
@@ -5,7 +5,7 @@ import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
-import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
+import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar';
@@ -23,7 +23,8 @@ interface HeaderMobileMenuProps {
// Auto mode
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
- onOpenAutoModeSettings: () => void;
+ skipVerificationInAutoMode: boolean;
+ onSkipVerificationChange: (value: boolean) => void;
// Plan button
onOpenPlanDialog: () => void;
// Usage bar visibility
@@ -41,7 +42,8 @@ export function HeaderMobileMenu({
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
- onOpenAutoModeSettings,
+ skipVerificationInAutoMode,
+ onSkipVerificationChange,
onOpenPlanDialog,
showClaudeUsage,
showCodexUsage,
@@ -66,22 +68,23 @@ export function HeaderMobileMenu({
Controls
- {/* Auto Mode Toggle */}
- onAutoModeToggle(!isAutoModeRunning)}
- data-testid="mobile-auto-mode-toggle-container"
- >
-
-
- Auto Mode
-
-
+ {/* Auto Mode Section */}
+
+ {/* Auto Mode Toggle */}
+
onAutoModeToggle(!isAutoModeRunning)}
+ data-testid="mobile-auto-mode-toggle-container"
+ >
+
+
+ Auto Mode
+
e.stopPropagation()}
data-testid="mobile-auto-mode-toggle"
/>
- {
- e.stopPropagation();
- onOpenAutoModeSettings();
- }}
- className="p-1 rounded hover:bg-accent/50 transition-colors"
- title="Auto Mode Settings"
- data-testid="mobile-auto-mode-settings-button"
- >
-
-
+
+
+ {/* Skip Verification Toggle */}
+
onSkipVerificationChange(!skipVerificationInAutoMode)}
+ data-testid="mobile-skip-verification-toggle-container"
+ >
+
+
+ Skip Verification
+
+
e.stopPropagation()}
+ data-testid="mobile-skip-verification-toggle"
+ />
+
+
+ {/* Concurrency Control */}
+
+
+
+ Max Agents
+
+ {runningAgentsCount}/{maxConcurrency}
+
+
+
onConcurrencyChange(value[0])}
+ min={1}
+ max={10}
+ step={1}
+ className="w-full"
+ data-testid="mobile-concurrency-slider"
+ />
@@ -122,32 +159,6 @@ export function HeaderMobileMenu({
/>
- {/* Concurrency Control */}
-
-
-
- Max Agents
-
- {runningAgentsCount}/{maxConcurrency}
-
-
-
onConcurrencyChange(value[0])}
- min={1}
- max={10}
- step={1}
- className="w-full"
- data-testid="mobile-concurrency-slider"
- />
-
-
{/* Plan Button */}
{
if (result.success && result.title) {
const titleUpdates = {
@@ -245,10 +252,12 @@ export function useBoardActions({
updateFeature,
saveCategory,
currentProject,
+ projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
+ currentWorktreeBranch,
]
);
@@ -282,7 +291,9 @@ export function useBoardActions({
let finalBranchName: string | undefined;
if (workMode === 'current') {
- finalBranchName = undefined;
+ // Work directly on current branch - use the current worktree's branch if not on main
+ // This ensures features updated on a non-main worktree are associated with that worktree
+ finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -397,6 +408,7 @@ export function useBoardActions({
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
+ currentWorktreeBranch,
]
);
@@ -474,10 +486,30 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
- if (!autoMode.canStartNewTask) {
+ // Check capacity for the feature's specific worktree, not the current view
+ // Normalize the branch name: if the feature's branch is the primary worktree branch,
+ // treat it as null (main worktree) to match how running tasks are stored
+ const rawBranchName = feature.branchName ?? null;
+ const featureBranchName =
+ currentProject?.path &&
+ rawBranchName &&
+ isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
+ ? null
+ : rawBranchName;
+ const featureWorktreeState = currentProject
+ ? getAutoModeState(currentProject.id, featureBranchName)
+ : null;
+ const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
+ const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
+ const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
+
+ if (!canStartInWorktree) {
+ const worktreeDesc = featureBranchName
+ ? `worktree "${featureBranchName}"`
+ : 'main worktree';
toast.error('Concurrency limit reached', {
- description: `You can only have ${autoMode.maxConcurrency} task${
- autoMode.maxConcurrency > 1 ? 's' : ''
+ description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
+ featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -541,34 +573,18 @@ export function useBoardActions({
updateFeature,
persistFeatureUpdate,
handleRunFeature,
+ currentProject,
+ getAutoModeState,
+ isPrimaryWorktreeBranch,
]
);
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
-
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- logger.error('Auto mode API not available');
- return;
- }
-
- const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
-
- if (result.success) {
- logger.info('Feature verification started successfully');
- } else {
- logger.error('Failed to verify feature:', result.error);
- await loadFeatures();
- }
- } catch (error) {
- logger.error('Error verifying feature:', error);
- await loadFeatures();
- }
+ verifyFeatureMutation.mutate(feature.id);
},
- [currentProject, loadFeatures]
+ [currentProject, verifyFeatureMutation]
);
const handleResumeFeature = useCallback(
@@ -578,40 +594,9 @@ export function useBoardActions({
logger.error('No current project');
return;
}
-
- try {
- const api = getElectronAPI();
- if (!api?.autoMode) {
- logger.error('Auto mode API not available');
- return;
- }
-
- logger.info('Calling resumeFeature API...', {
- projectPath: currentProject.path,
- featureId: feature.id,
- useWorktrees,
- });
-
- const result = await api.autoMode.resumeFeature(
- currentProject.path,
- feature.id,
- useWorktrees
- );
-
- logger.info('resumeFeature result:', result);
-
- if (result.success) {
- logger.info('Feature resume started successfully');
- } else {
- logger.error('Failed to resume feature:', result.error);
- await loadFeatures();
- }
- } catch (error) {
- logger.error('Error resuming feature:', error);
- await loadFeatures();
- }
+ resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
},
- [currentProject, loadFeatures, useWorktrees]
+ [currentProject, resumeFeatureMutation, useWorktrees]
);
const handleManualVerify = useCallback(
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts
index 1d831f4b..6505da2a 100644
--- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts
+++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts
@@ -1,7 +1,11 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
-import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
+import {
+ createFeatureMap,
+ getBlockingDependenciesFromMap,
+ resolveDependencies,
+} from '@automaker/dependency-resolver';
type ColumnId = Feature['status'];
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
verified: [],
completed: [], // Completed features are shown in the archive modal, not as a column
};
+ const featureMap = createFeatureMap(features);
+ const runningTaskIds = new Set(runningAutoTasks);
// Filter features by search query (case-insensitive)
const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
filteredFeatures.forEach((f) => {
// If feature has a running agent, always show it in "in_progress"
- const isRunning = runningAutoTasks.includes(f.id);
+ const isRunning = runningTaskIds.has(f.id);
// Check if feature matches the current worktree by branchName
// Features without branchName are considered unassigned (show only on primary worktree)
@@ -97,8 +103,25 @@ export function useBoardColumnFeatures({
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
+ // NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
+ // it's already running for the current worktree. However, we still need to check matchesWorktree
+ // to ensure the feature's branchName matches the current worktree's branch.
if (isRunning) {
- if (!matchesWorktree) return;
+ // If feature is running but doesn't match worktree, it might be a timing issue where
+ // the feature was started for a different worktree. Still show it if it's running to
+ // prevent disappearing features, but log a warning.
+ if (!matchesWorktree) {
+ // This can happen if:
+ // 1. Feature was started for a different worktree (bug)
+ // 2. Timing issue where branchName hasn't been set yet
+ // 3. User switched worktrees while feature was starting
+ // Still show it in in_progress to prevent it from disappearing
+ console.debug(
+ `Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
+ );
+ map.in_progress.push(f);
+ return;
+ }
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];
@@ -151,7 +174,6 @@ export function useBoardColumnFeatures({
const { orderedFeatures } = resolveDependencies(map.backlog);
// Get all features to check blocking dependencies against
- const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
@@ -161,7 +183,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
- if (getBlockingDependencies(f, allFeatures).length > 0) {
+ if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts
index 466d7cca..327a2892 100644
--- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts
+++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts
@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop');
+export interface PendingDependencyLink {
+ draggedFeature: Feature;
+ targetFeature: Feature;
+}
+
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
handleStartImplementation,
}: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState(null);
- const { moveFeature } = useAppStore();
+ const [pendingDependencyLink, setPendingDependencyLink] = useState(
+ null
+ );
+ const { moveFeature, updateFeature } = useAppStore();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
[features]
);
+ // Clear pending dependency link
+ const clearPendingDependencyLink = useCallback(() => {
+ setPendingDependencyLink(null);
+ }, []);
+
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
@@ -57,6 +70,92 @@ export function useBoardDragDrop({
// Check if this is a running task (non-skipTests, TDD)
const isRunningTask = runningAutoTasks.includes(featureId);
+ // Check if dropped on another card (for creating dependency links)
+ if (overId.startsWith('card-drop-')) {
+ const cardData = over.data.current as {
+ type: string;
+ featureId: string;
+ };
+
+ if (cardData?.type === 'card') {
+ const targetFeatureId = cardData.featureId;
+
+ // Don't link to self
+ if (targetFeatureId === featureId) {
+ return;
+ }
+
+ const targetFeature = features.find((f) => f.id === targetFeatureId);
+ if (!targetFeature) return;
+
+ // Only allow linking backlog features (both must be in backlog)
+ if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
+ toast.error('Cannot link features', {
+ description: 'Both features must be in the backlog to create a dependency link.',
+ });
+ return;
+ }
+
+ // Set pending dependency link to trigger dialog
+ setPendingDependencyLink({
+ draggedFeature,
+ targetFeature,
+ });
+ return;
+ }
+ }
+
+ // Check if dropped on a worktree tab
+ if (overId.startsWith('worktree-drop-')) {
+ // Handle dropping on a worktree - change the feature's branchName
+ const worktreeData = over.data.current as {
+ type: string;
+ branch: string;
+ path: string;
+ isMain: boolean;
+ };
+
+ if (worktreeData?.type === 'worktree') {
+ // Don't allow moving running tasks to a different worktree
+ if (isRunningTask) {
+ logger.debug('Cannot move running feature to different worktree');
+ toast.error('Cannot move feature', {
+ description: 'This feature is currently running and cannot be moved.',
+ });
+ return;
+ }
+
+ const targetBranch = worktreeData.branch;
+ const currentBranch = draggedFeature.branchName;
+
+ // For main worktree, set branchName to null to indicate it should use main
+ // (must use null not undefined so it serializes to JSON for the API call)
+ // For other worktrees, set branchName to the target branch
+ const newBranchName = worktreeData.isMain ? null : targetBranch;
+
+ // If already on the same branch, nothing to do
+ // For main worktree: feature with null/undefined branchName is already on main
+ // For other worktrees: compare branch names directly
+ const isAlreadyOnTarget = worktreeData.isMain
+ ? !currentBranch // null or undefined means already on main
+ : currentBranch === targetBranch;
+
+ if (isAlreadyOnTarget) {
+ return;
+ }
+
+ // Update feature's branchName
+ updateFeature(featureId, { branchName: newBranchName });
+ await persistFeatureUpdate(featureId, { branchName: newBranchName });
+
+ const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
+ toast.success('Feature moved to branch', {
+ description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
+ });
+ return;
+ }
+ }
+
// Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag)
@@ -205,12 +304,21 @@ export function useBoardDragDrop({
}
}
},
- [features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
+ [
+ features,
+ runningAutoTasks,
+ moveFeature,
+ updateFeature,
+ persistFeatureUpdate,
+ handleStartImplementation,
+ ]
);
return {
activeFeature,
handleDragStart,
handleDragEnd,
+ pendingDependencyLink,
+ clearPendingDependencyLink,
};
}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts
index 1a7eda53..df352b01 100644
--- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts
+++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts
@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
-import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
@@ -65,37 +64,8 @@ export function useBoardEffects({
};
}, [specCreatingForProject, setSpecCreatingForProject]);
- // Sync running tasks from electron backend on mount
- useEffect(() => {
- if (!currentProject) return;
-
- const syncRunningTasks = async () => {
- try {
- const api = getElectronAPI();
- if (!api?.autoMode?.status) return;
-
- const status = await api.autoMode.status(currentProject.path);
- if (status.success) {
- const projectId = currentProject.id;
- const { clearRunningTasks, addRunningTask } = useAppStore.getState();
-
- if (status.runningFeatures) {
- logger.info('Syncing running tasks from backend:', status.runningFeatures);
-
- clearRunningTasks(projectId);
-
- status.runningFeatures.forEach((featureId: string) => {
- addRunningTask(projectId, featureId);
- });
- }
- }
- } catch (error) {
- logger.error('Failed to sync running tasks:', error);
- }
- };
-
- syncRunningTasks();
- }, [currentProject]);
+ // Note: Running tasks sync is now handled by useAutoMode hook in BoardView
+ // which correctly handles worktree/branch scoping.
// Check which features have context files
useEffect(() => {
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts
index e457e02e..ebdd5034 100644
--- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts
+++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts
@@ -1,8 +1,18 @@
-import { useState, useCallback, useEffect, useRef } from 'react';
-import { useAppStore, Feature } from '@/store/app-store';
+/**
+ * Board Features Hook
+ *
+ * React Query-based hook for managing features on the board view.
+ * Handles feature loading, categories, and auto-mode event notifications.
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
+import { useFeatures } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardFeatures');
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
}
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
- const { features, setFeatures } = useAppStore();
- const [isLoading, setIsLoading] = useState(true);
+ const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState([]);
- // Track previous project path to detect project switches
- const prevProjectPathRef = useRef(null);
- const isInitialLoadRef = useRef(true);
- const isSwitchingProjectRef = useRef(false);
-
- // Load features using features API
- // IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
- const loadFeatures = useCallback(async () => {
- if (!currentProject) return;
-
- const currentPath = currentProject.path;
- const previousPath = prevProjectPathRef.current;
- const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
-
- // Get cached features from store (without adding to dependencies)
- const cachedFeatures = useAppStore.getState().features;
-
- // If project switched, mark it but don't clear features yet
- // We'll clear after successful API load to prevent data loss
- if (isProjectSwitch) {
- logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
- isSwitchingProjectRef.current = true;
- isInitialLoadRef.current = true;
- }
-
- // Update the ref to track current project
- prevProjectPathRef.current = currentPath;
-
- // Only show loading spinner on initial load to prevent board flash during reloads
- if (isInitialLoadRef.current) {
- setIsLoading(true);
- }
-
- try {
- const api = getElectronAPI();
- if (!api.features) {
- logger.error('Features API not available');
- // Keep cached features if API is unavailable
- return;
- }
-
- const result = await api.features.getAll(currentProject.path);
-
- if (result.success && result.features) {
- const featuresWithIds = result.features.map((f: any, index: number) => ({
- ...f,
- id: f.id || `feature-${index}-${Date.now()}`,
- status: f.status || 'backlog',
- startedAt: f.startedAt, // Preserve startedAt timestamp
- // Ensure model and thinkingLevel are set for backward compatibility
- model: f.model || 'opus',
- thinkingLevel: f.thinkingLevel || 'none',
- }));
- // Successfully loaded features - now safe to set them
- setFeatures(featuresWithIds);
-
- // Only clear categories on project switch AFTER successful load
- if (isProjectSwitch) {
- setPersistedCategories([]);
- }
-
- // Check for interrupted features and resume them
- // This handles server restarts where features were in pipeline steps
- if (api.autoMode?.resumeInterrupted) {
- try {
- await api.autoMode.resumeInterrupted(currentProject.path);
- logger.info('Checked for interrupted features');
- } catch (resumeError) {
- logger.warn('Failed to check for interrupted features:', resumeError);
- }
- }
- } else if (!result.success && result.error) {
- logger.error('API returned error:', result.error);
- // If it's a new project or the error indicates no features found,
- // that's expected - start with empty array
- if (isProjectSwitch) {
- setFeatures([]);
- setPersistedCategories([]);
- }
- // Otherwise keep cached features
- }
- } catch (error) {
- logger.error('Failed to load features:', error);
- // On error, keep existing cached features for the current project
- // Only clear on project switch if we have no features from server
- if (isProjectSwitch && cachedFeatures.length === 0) {
- setFeatures([]);
- setPersistedCategories([]);
- }
- } finally {
- setIsLoading(false);
- isInitialLoadRef.current = false;
- isSwitchingProjectRef.current = false;
- }
- }, [currentProject, setFeatures]);
+ // Use React Query for features
+ const {
+ data: features = [],
+ isLoading,
+ refetch: loadFeatures,
+ } = useFeatures(currentProject?.path);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
@@ -125,15 +45,12 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(parsed);
}
} else {
- // File doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
- } catch (error) {
- logger.error('Failed to load categories:', error);
- // If file doesn't exist, ensure categories are cleared
+ } catch {
setPersistedCategories([]);
}
- }, [currentProject]);
+ }, [currentProject, loadFeatures]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
-
- // Read existing categories
let categories: string[] = [...persistedCategories];
- // Add new category if it doesn't exist
if (!categories.includes(category)) {
categories.push(category);
- categories.sort(); // Keep sorted
+ categories.sort();
- // Write back to file
await api.writeFile(
`${currentProject.path}/.automaker/categories.json`,
JSON.stringify(categories, null, 2)
);
- // Update state
setPersistedCategories(categories);
}
} catch (error) {
@@ -167,42 +79,38 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
[currentProject, persistedCategories]
);
- // Subscribe to spec regeneration complete events to refresh kanban board
- useEffect(() => {
- const api = getElectronAPI();
- if (!api.specRegeneration) return;
-
- const unsubscribe = api.specRegeneration.onEvent((event) => {
- // Refresh the kanban board when spec regeneration completes for the current project
- if (
- event.type === 'spec_regeneration_complete' &&
- currentProject &&
- event.projectPath === currentProject.path
- ) {
- logger.info('Spec regeneration complete, refreshing features');
- loadFeatures();
- }
- });
-
- return () => {
- unsubscribe();
- };
- }, [currentProject, loadFeatures]);
-
- // Listen for auto mode feature completion and errors to reload features
+ // Subscribe to auto mode events for notifications (ding sound, toasts)
+ // Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
+ const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => {
+ // Check if event is for the current project by matching projectPath
+ const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
+ if (eventProjectPath && eventProjectPath !== projectPath) {
+ // Event is for a different project, ignore it
+ logger.debug(
+ `Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
+ );
+ return;
+ }
+
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
- if (event.type === 'auto_mode_feature_complete') {
+ if (event.type === 'auto_mode_feature_start') {
+ // Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
+ logger.info(
+ `[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
+ );
+ loadFeatures();
+ } else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();
@@ -212,28 +120,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const audio = new Audio('/sounds/ding.mp3');
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
- } else if (event.type === 'plan_approval_required') {
- // Reload features when plan is generated and requires approval
- // This ensures the feature card shows the "Approve Plan" button
- logger.info('Plan approval required, reloading features...');
- loadFeatures();
- } else if (event.type === 'pipeline_step_started') {
- // Pipeline steps update the feature status to `pipeline_*` before the step runs.
- // Reload so the card moves into the correct pipeline column immediately.
- logger.info('Pipeline step started, reloading features...');
- loadFeatures();
} else if (event.type === 'auto_mode_error') {
- // Reload features when an error occurs (feature moved to waiting_approval)
- logger.info('Feature error, reloading features...', event.error);
-
- // Remove from running tasks so it moves to the correct column
+ // Remove from running tasks
if (event.featureId) {
- removeRunningTask(eventProjectId, event.featureId);
+ const eventBranchName =
+ 'branchName' in event && event.branchName !== undefined ? event.branchName : null;
+ removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
- loadFeatures();
-
- // Check for authentication errors and show a more helpful message
+ // Show error toast
const isAuthError =
event.errorType === 'authentication' ||
(event.error &&
@@ -255,22 +150,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
});
return unsubscribe;
- }, [loadFeatures, currentProject]);
+ }, [currentProject]);
+ // Check for interrupted features on mount
useEffect(() => {
- loadFeatures();
- }, [loadFeatures]);
+ if (!currentProject) return;
- // Load persisted categories on mount
+ const checkInterrupted = async () => {
+ const api = getElectronAPI();
+ if (api.autoMode?.resumeInterrupted) {
+ try {
+ await api.autoMode.resumeInterrupted(currentProject.path);
+ logger.info('Checked for interrupted features');
+ } catch (error) {
+ logger.warn('Failed to check for interrupted features:', error);
+ }
+ }
+ };
+
+ checkInterrupted();
+ }, [currentProject]);
+
+ // Load persisted categories on mount/project change
useEffect(() => {
loadCategories();
}, [loadCategories]);
+ // Clear categories when project changes
+ useEffect(() => {
+ setPersistedCategories([]);
+ }, [currentProject?.path]);
+
return {
features,
isLoading,
persistedCategories,
- loadFeatures,
+ loadFeatures: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject?.path ?? ''),
+ });
+ },
loadCategories,
saveCategory,
};
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
index 9ce47c83..4c809631 100644
--- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
+++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts
@@ -1,8 +1,10 @@
import { useCallback } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
+import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardPersistence');
@@ -12,6 +14,7 @@ interface UseBoardPersistenceProps {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
+ const queryClient = useQueryClient();
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
@@ -45,7 +48,21 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
feature: result.feature,
});
if (result.success && result.feature) {
- updateFeature(result.feature.id, result.feature);
+ const updatedFeature = result.feature;
+ updateFeature(updatedFeature.id, updatedFeature);
+ queryClient.setQueryData(
+ queryKeys.features.all(currentProject.path),
+ (features) => {
+ if (!features) return features;
+ return features.map((feature) =>
+ feature.id === updatedFeature.id ? updatedFeature : feature
+ );
+ }
+ );
+ // Invalidate React Query cache to sync UI
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject.path),
+ });
} else if (!result.success) {
logger.error('API features.update failed', result);
}
@@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
logger.error('Failed to persist feature update:', error);
}
},
- [currentProject, updateFeature]
+ [currentProject, updateFeature, queryClient]
);
// Persist feature creation to API
@@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
const result = await api.features.create(currentProject.path, feature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
+ // Invalidate React Query cache to sync UI
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject.path),
+ });
}
} catch (error) {
logger.error('Failed to persist feature creation:', error);
}
},
- [currentProject, updateFeature]
+ [currentProject, updateFeature, queryClient]
);
// Persist feature deletion to API
@@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
}
await api.features.delete(currentProject.path, featureId);
+ // Invalidate React Query cache to sync UI
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject.path),
+ });
} catch (error) {
logger.error('Failed to persist feature deletion:', error);
}
},
- [currentProject]
+ [currentProject, queryClient]
);
return {
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/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx
index 6ace0e76..8314e74f 100644
--- a/apps/ui/src/components/views/board-view/kanban-board.tsx
+++ b/apps/ui/src/components/views/board-view/kanban-board.tsx
@@ -1,5 +1,13 @@
-import { useMemo } from 'react';
-import { DndContext, DragOverlay } from '@dnd-kit/core';
+import {
+ useMemo,
+ useRef,
+ useState,
+ useCallback,
+ useEffect,
+ type RefObject,
+ type ReactNode,
+} from 'react';
+import { DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
@@ -10,10 +18,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
- sensors: any;
- collisionDetectionStrategy: (args: any) => any;
- onDragStart: (event: any) => void;
- onDragEnd: (event: any) => void;
activeFeature: Feature | null;
getColumnFeatures: (columnId: ColumnId) => Feature[];
backgroundImageStyle: React.CSSProperties;
@@ -64,11 +68,200 @@ interface KanbanBoardProps {
className?: string;
}
+const KANBAN_VIRTUALIZATION_THRESHOLD = 40;
+const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220;
+const KANBAN_CARD_GAP_PX = 10;
+const KANBAN_OVERSCAN_COUNT = 6;
+const VIRTUALIZATION_MEASURE_EPSILON_PX = 1;
+const REDUCED_CARD_OPACITY_PERCENT = 85;
+
+type VirtualListItem = { id: string };
+
+interface VirtualListState- {
+ contentRef: RefObject
;
+ onScroll: (event: UIEvent) => void;
+ itemIds: string[];
+ visibleItems: Item[];
+ totalHeight: number;
+ offsetTop: number;
+ startIndex: number;
+ shouldVirtualize: boolean;
+ registerItem: (id: string) => (node: HTMLDivElement | null) => void;
+}
+
+interface VirtualizedListProps- {
+ items: Item[];
+ isDragging: boolean;
+ estimatedItemHeight: number;
+ itemGap: number;
+ overscan: number;
+ virtualizationThreshold: number;
+ children: (state: VirtualListState
- ) => ReactNode;
+}
+
+function findIndexForOffset(itemEnds: number[], offset: number): number {
+ let low = 0;
+ let high = itemEnds.length - 1;
+ let result = itemEnds.length;
+
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ if (itemEnds[mid] >= offset) {
+ result = mid;
+ high = mid - 1;
+ } else {
+ low = mid + 1;
+ }
+ }
+
+ return Math.min(result, itemEnds.length - 1);
+}
+
+// Virtualize long columns while keeping full DOM during drag interactions.
+function VirtualizedList
- ({
+ items,
+ isDragging,
+ estimatedItemHeight,
+ itemGap,
+ overscan,
+ virtualizationThreshold,
+ children,
+}: VirtualizedListProps
- ) {
+ const contentRef = useRef
(null);
+ const measurementsRef = useRef>(new Map());
+ const scrollRafRef = useRef(null);
+ const [scrollTop, setScrollTop] = useState(0);
+ const [viewportHeight, setViewportHeight] = useState(0);
+ const [measureVersion, setMeasureVersion] = useState(0);
+
+ const itemIds = useMemo(() => items.map((item) => item.id), [items]);
+ const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold;
+
+ const itemSizes = useMemo(() => {
+ return items.map((item) => {
+ const measured = measurementsRef.current.get(item.id);
+ const resolvedHeight = measured ?? estimatedItemHeight;
+ return resolvedHeight + itemGap;
+ });
+ }, [items, estimatedItemHeight, itemGap, measureVersion]);
+
+ const itemStarts = useMemo(() => {
+ let offset = 0;
+ return itemSizes.map((size) => {
+ const start = offset;
+ offset += size;
+ return start;
+ });
+ }, [itemSizes]);
+
+ const itemEnds = useMemo(() => {
+ return itemStarts.map((start, index) => start + itemSizes[index]);
+ }, [itemStarts, itemSizes]);
+
+ const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0;
+
+ const { startIndex, endIndex, offsetTop } = useMemo(() => {
+ if (!shouldVirtualize || items.length === 0) {
+ return { startIndex: 0, endIndex: items.length, offsetTop: 0 };
+ }
+
+ const firstVisible = findIndexForOffset(itemEnds, scrollTop);
+ const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight);
+ const overscannedStart = Math.max(0, firstVisible - overscan);
+ const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1);
+
+ return {
+ startIndex: overscannedStart,
+ endIndex: overscannedEnd,
+ offsetTop: itemStarts[overscannedStart] ?? 0,
+ };
+ }, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]);
+
+ const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items;
+
+ const onScroll = useCallback((event: UIEvent) => {
+ const target = event.currentTarget;
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ scrollRafRef.current = requestAnimationFrame(() => {
+ setScrollTop(target.scrollTop);
+ scrollRafRef.current = null;
+ });
+ }, []);
+
+ const registerItem = useCallback(
+ (id: string) => (node: HTMLDivElement | null) => {
+ if (!node || !shouldVirtualize) return;
+ const measuredHeight = node.getBoundingClientRect().height;
+ const previousHeight = measurementsRef.current.get(id);
+ if (
+ previousHeight === undefined ||
+ Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX
+ ) {
+ measurementsRef.current.set(id, measuredHeight);
+ setMeasureVersion((value) => value + 1);
+ }
+ },
+ [shouldVirtualize]
+ );
+
+ useEffect(() => {
+ const container = contentRef.current;
+ if (!container || typeof window === 'undefined') return;
+
+ const updateHeight = () => {
+ setViewportHeight(container.clientHeight);
+ };
+
+ updateHeight();
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateHeight);
+ return () => window.removeEventListener('resize', updateHeight);
+ }
+
+ const observer = new ResizeObserver(() => updateHeight());
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (!shouldVirtualize) return;
+ const currentIds = new Set(items.map((item) => item.id));
+ for (const id of measurementsRef.current.keys()) {
+ if (!currentIds.has(id)) {
+ measurementsRef.current.delete(id);
+ }
+ }
+ }, [items, shouldVirtualize]);
+
+ useEffect(() => {
+ return () => {
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ };
+ }, []);
+
+ return (
+ <>
+ {children({
+ contentRef,
+ onScroll,
+ itemIds,
+ visibleItems,
+ totalHeight,
+ offsetTop,
+ startIndex,
+ shouldVirtualize,
+ registerItem,
+ })}
+ >
+ );
+}
+
export function KanbanBoard({
- sensors,
- collisionDetectionStrategy,
- onDragStart,
- onDragEnd,
activeFeature,
getColumnFeatures,
backgroundImageStyle,
@@ -109,7 +302,7 @@ export function KanbanBoard({
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
- const { keyboardShortcuts } = useAppStore();
+ const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
@@ -125,80 +318,125 @@ export function KanbanBoard({
)}
style={backgroundImageStyle}
>
-
-
- {columns.map((column) => {
- const columnFeatures = getColumnFeatures(column.id as ColumnId);
- return (
-
- {columnFeatures.length > 0 && (
+
+ {columns.map((column) => {
+ const columnFeatures = getColumnFeatures(column.id as ColumnId);
+ return (
+
+ {({
+ contentRef,
+ onScroll,
+ itemIds,
+ visibleItems,
+ totalHeight,
+ offsetTop,
+ startIndex,
+ shouldVirtualize,
+ registerItem,
+ }) => (
+
+ {columnFeatures.length > 0 && (
+
+
+ Complete All
+
+ )}
-
- Complete All
+
+ {completedCount > 0 && (
+
+ {completedCount > 99 ? '99+' : completedCount}
+
+ )}
- )}
+
+ ) : column.id === 'backlog' ? (
+
+
+
+
+
onToggleSelectionMode?.('backlog')}
+ title={
+ selectionTarget === 'backlog'
+ ? 'Switch to Drag Mode'
+ : 'Select Multiple'
+ }
+ data-testid="selection-mode-button"
+ >
+ {selectionTarget === 'backlog' ? (
+ <>
+
+ Drag
+ >
+ ) : (
+ <>
+
+ Select
+ >
+ )}
+
+
+ ) : column.id === 'waiting_approval' ? (
-
- {completedCount > 0 && (
-
- {completedCount > 99 ? '99+' : completedCount}
-
- )}
-
-
- ) : column.id === 'backlog' ? (
-
-
-
-
-
onToggleSelectionMode?.('backlog')}
+ className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
+ onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
- selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
+ selectionTarget === 'waiting_approval'
+ ? 'Switch to Drag Mode'
+ : 'Select Multiple'
}
- data-testid="selection-mode-button"
+ data-testid="waiting-approval-selection-mode-button"
>
- {selectionTarget === 'backlog' ? (
+ {selectionTarget === 'waiting_approval' ? (
<>
Drag
@@ -210,178 +448,216 @@ export function KanbanBoard({
>
)}
-
- ) : column.id === 'waiting_approval' ? (
- onToggleSelectionMode?.('waiting_approval')}
- title={
- selectionTarget === 'waiting_approval'
- ? 'Switch to Drag Mode'
- : 'Select Multiple'
- }
- data-testid="waiting-approval-selection-mode-button"
- >
- {selectionTarget === 'waiting_approval' ? (
- <>
-
- Drag
- >
- ) : (
- <>
-
- Select
- >
- )}
-
- ) : column.id === 'in_progress' ? (
-
-
-
- ) : column.isPipelineStep ? (
-
-
-
- ) : undefined
- }
- footerAction={
- column.id === 'backlog' ? (
-
-
- Add Feature
-
- {formatShortcut(addFeatureShortcut, true)}
-
-
- ) : undefined
- }
- >
- f.id)}
- strategy={verticalListSortingStrategy}
+ ) : column.id === 'in_progress' ? (
+
+
+
+ ) : column.isPipelineStep ? (
+
+
+
+ ) : undefined
+ }
+ footerAction={
+ column.id === 'backlog' ? (
+
+
+ Add Feature
+
+ {formatShortcut(addFeatureShortcut, true)}
+
+
+ ) : undefined
+ }
>
- {/* Empty state card when column has no features */}
- {columnFeatures.length === 0 && !isDragging && (
-
- )}
- {columnFeatures.map((feature, index) => {
- // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
- let shortcutKey: string | undefined;
- if (column.id === 'in_progress' && index < 10) {
- shortcutKey = index === 9 ? '0' : String(index + 1);
- }
- return (
- onEdit(feature)}
- onDelete={() => onDelete(feature.id)}
- onViewOutput={() => onViewOutput(feature)}
- onVerify={() => onVerify(feature)}
- onResume={() => onResume(feature)}
- onForceStop={() => onForceStop(feature)}
- onManualVerify={() => onManualVerify(feature)}
- onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
- onFollowUp={() => onFollowUp(feature)}
- onComplete={() => onComplete(feature)}
- onImplement={() => onImplement(feature)}
- onViewPlan={() => onViewPlan(feature)}
- onApprovePlan={() => onApprovePlan(feature)}
- onSpawnTask={() => onSpawnTask?.(feature)}
- hasContext={featuresWithContext.has(feature.id)}
- isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
- shortcutKey={shortcutKey}
- opacity={backgroundSettings.cardOpacity}
- glassmorphism={backgroundSettings.cardGlassmorphism}
- cardBorderEnabled={backgroundSettings.cardBorderEnabled}
- cardBorderOpacity={backgroundSettings.cardBorderOpacity}
- isSelectionMode={isSelectionMode}
- selectionTarget={selectionTarget}
- isSelected={selectedFeatureIds.has(feature.id)}
- onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
- />
- );
- })}
-
-
- );
- })}
-
+ {(() => {
+ const reduceEffects = shouldVirtualize;
+ const effectiveCardOpacity = reduceEffects
+ ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
+ : backgroundSettings.cardOpacity;
+ const effectiveGlassmorphism =
+ backgroundSettings.cardGlassmorphism && !reduceEffects;
-
- {activeFeature && (
-
- {}}
- onDelete={() => {}}
- onViewOutput={() => {}}
- onVerify={() => {}}
- onResume={() => {}}
- onForceStop={() => {}}
- onManualVerify={() => {}}
- onMoveBackToInProgress={() => {}}
- onFollowUp={() => {}}
- onImplement={() => {}}
- onComplete={() => {}}
- onViewPlan={() => {}}
- onApprovePlan={() => {}}
- onSpawnTask={() => {}}
- hasContext={featuresWithContext.has(activeFeature.id)}
- isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
- opacity={backgroundSettings.cardOpacity}
- glassmorphism={backgroundSettings.cardGlassmorphism}
- cardBorderEnabled={backgroundSettings.cardBorderEnabled}
- cardBorderOpacity={backgroundSettings.cardBorderOpacity}
- />
-
- )}
-
-
+ return (
+
+ {/* Empty state card when column has no features */}
+ {columnFeatures.length === 0 && !isDragging && (
+
+ )}
+ {shouldVirtualize ? (
+
+
+ {visibleItems.map((feature, index) => {
+ const absoluteIndex = startIndex + index;
+ let shortcutKey: string | undefined;
+ if (column.id === 'in_progress' && absoluteIndex < 10) {
+ shortcutKey =
+ absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
+ }
+ return (
+
+ onEdit(feature)}
+ onDelete={() => onDelete(feature.id)}
+ onViewOutput={() => onViewOutput(feature)}
+ onVerify={() => onVerify(feature)}
+ onResume={() => onResume(feature)}
+ onForceStop={() => onForceStop(feature)}
+ onManualVerify={() => onManualVerify(feature)}
+ onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
+ onFollowUp={() => onFollowUp(feature)}
+ onComplete={() => onComplete(feature)}
+ onImplement={() => onImplement(feature)}
+ onViewPlan={() => onViewPlan(feature)}
+ onApprovePlan={() => onApprovePlan(feature)}
+ onSpawnTask={() => onSpawnTask?.(feature)}
+ hasContext={featuresWithContext.has(feature.id)}
+ isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
+ shortcutKey={shortcutKey}
+ opacity={effectiveCardOpacity}
+ glassmorphism={effectiveGlassmorphism}
+ cardBorderEnabled={backgroundSettings.cardBorderEnabled}
+ cardBorderOpacity={backgroundSettings.cardBorderOpacity}
+ reduceEffects={reduceEffects}
+ isSelectionMode={isSelectionMode}
+ selectionTarget={selectionTarget}
+ isSelected={selectedFeatureIds.has(feature.id)}
+ onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
+ />
+
+ );
+ })}
+
+
+ ) : (
+ columnFeatures.map((feature, index) => {
+ let shortcutKey: string | undefined;
+ if (column.id === 'in_progress' && index < 10) {
+ shortcutKey = index === 9 ? '0' : String(index + 1);
+ }
+ return (
+ onEdit(feature)}
+ onDelete={() => onDelete(feature.id)}
+ onViewOutput={() => onViewOutput(feature)}
+ onVerify={() => onVerify(feature)}
+ onResume={() => onResume(feature)}
+ onForceStop={() => onForceStop(feature)}
+ onManualVerify={() => onManualVerify(feature)}
+ onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
+ onFollowUp={() => onFollowUp(feature)}
+ onComplete={() => onComplete(feature)}
+ onImplement={() => onImplement(feature)}
+ onViewPlan={() => onViewPlan(feature)}
+ onApprovePlan={() => onApprovePlan(feature)}
+ onSpawnTask={() => onSpawnTask?.(feature)}
+ hasContext={featuresWithContext.has(feature.id)}
+ isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
+ shortcutKey={shortcutKey}
+ opacity={effectiveCardOpacity}
+ glassmorphism={effectiveGlassmorphism}
+ cardBorderEnabled={backgroundSettings.cardBorderEnabled}
+ cardBorderOpacity={backgroundSettings.cardBorderOpacity}
+ reduceEffects={reduceEffects}
+ isSelectionMode={isSelectionMode}
+ selectionTarget={selectionTarget}
+ isSelected={selectedFeatureIds.has(feature.id)}
+ onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
+ />
+ );
+ })
+ )}
+
+ );
+ })()}
+
+ )}
+
+ );
+ })}
+
+
+
+ {activeFeature && (
+
+ {}}
+ onDelete={() => {}}
+ onViewOutput={() => {}}
+ onVerify={() => {}}
+ onResume={() => {}}
+ onForceStop={() => {}}
+ onManualVerify={() => {}}
+ onMoveBackToInProgress={() => {}}
+ onFollowUp={() => {}}
+ onImplement={() => {}}
+ onComplete={() => {}}
+ onViewPlan={() => {}}
+ onApprovePlan={() => {}}
+ onSpawnTask={() => {}}
+ hasContext={featuresWithContext.has(activeFeature.id)}
+ isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
+ opacity={backgroundSettings.cardOpacity}
+ glassmorphism={backgroundSettings.cardGlassmorphism}
+ cardBorderEnabled={backgroundSettings.cardBorderEnabled}
+ cardBorderOpacity={backgroundSettings.cardBorderOpacity}
+ />
+
+ )}
+
);
}
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/enhancement/enhance-with-ai.tsx b/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx
index 63b9dedc..3429584b 100644
--- a/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx
+++ b/apps/ui/src/components/views/board-view/shared/enhancement/enhance-with-ai.tsx
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
+import { useAppStore } from '@/store/app-store';
const logger = createLogger('EnhanceWithAI');
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
const [enhancementMode, setEnhancementMode] = useState
('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
+ // Get current project path for per-project Claude API profile
+ const currentProjectPath = useAppStore((state) => state.currentProject?.path);
+
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
value,
enhancementMode,
enhancementOverride.effectiveModel,
- enhancementOverride.effectiveModelEntry.thinkingLevel
+ enhancementOverride.effectiveModelEntry.thinkingLevel,
+ currentProjectPath
);
if (result?.success && result.enhancedText) {
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..a6d7ef59 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';
@@ -132,11 +132,12 @@ export function DevServerLogsPanel({
return (
!isOpen && onClose()}>
{/* Compact Header */}
-
+
@@ -183,7 +184,7 @@ export function DevServerLogsPanel({
onClick={() => fetchLogs()}
title="Refresh logs"
>
-
+ {isLoading ? : }
@@ -234,7 +235,7 @@ export function DevServerLogsPanel({
>
{isLoading && !logs ? (
-
+
Loading logs...
) : !logs && !isRunning ? (
@@ -245,7 +246,7 @@ export function DevServerLogsPanel({
) : !logs ? (
-
+
Waiting for output...
Logs will appear as the server generates output
@@ -256,7 +257,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..8ba682d9 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
@@ -25,20 +25,34 @@ import {
AlertCircle,
RefreshCw,
Copy,
+ Eye,
ScrollText,
+ Sparkles,
+ Terminal,
+ SquarePlus,
+ SplitSquareHorizontal,
+ Undo2,
+ Zap,
} 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;
isSelected: boolean;
aheadCount: number;
behindCount: number;
+ hasRemoteBranch: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
@@ -47,21 +61,29 @@ interface WorktreeActionsDropdownProps {
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
+ /** Whether auto mode is running for this worktree */
+ isAutoModeRunning?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
+ onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
+ onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
+ onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
+ onViewChanges: (worktree: WorktreeInfo) => void;
+ onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
- onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
+ onToggleAutoMode?: (worktree: WorktreeInfo) => void;
+ onMerge: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -70,6 +92,7 @@ export function WorktreeActionsDropdown({
isSelected,
aheadCount,
behindCount,
+ hasRemoteBranch,
isPulling,
isPushing,
isStartingDevServer,
@@ -77,21 +100,28 @@ export function WorktreeActionsDropdown({
devServerInfo,
gitRepoStatus,
standalone = false,
+ isAutoModeRunning = false,
onOpenChange,
onPull,
onPush,
+ onPushNewBranch,
onOpenInEditor,
+ onOpenInIntegratedTerminal,
+ onOpenInExternalTerminal,
+ onViewChanges,
+ onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
- onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
+ onToggleAutoMode,
+ onMerge,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -108,6 +138,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;
@@ -187,6 +231,26 @@ export function WorktreeActionsDropdown({
>
)}
+ {/* Auto Mode toggle */}
+ {onToggleAutoMode && (
+ <>
+ {isAutoModeRunning ? (
+ onToggleAutoMode(worktree)} className="text-xs">
+
+
+
+
+ Stop Auto Mode
+
+ ) : (
+ onToggleAutoMode(worktree)} className="text-xs">
+
+ Start Auto Mode
+
+ )}
+
+ >
+ )}
canPerformGitOps && onPull(worktree)}
@@ -205,14 +269,27 @@ export function WorktreeActionsDropdown({
canPerformGitOps && onPush(worktree)}
- disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
+ onClick={() => {
+ if (!canPerformGitOps) return;
+ if (!hasRemoteBranch) {
+ onPushNewBranch(worktree);
+ } else {
+ onPush(worktree);
+ }
+ }}
+ disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
{isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && }
- {canPerformGitOps && aheadCount > 0 && (
+ {canPerformGitOps && !hasRemoteBranch && (
+
+
+ new
+
+ )}
+ {canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
{aheadCount} ahead
@@ -233,27 +310,6 @@ export function WorktreeActionsDropdown({
{!canPerformGitOps && }
- {!worktree.isMain && (
-
- canPerformGitOps && onMerge(worktree)}
- disabled={!canPerformGitOps}
- className={cn(
- 'text-xs text-green-600 focus:text-green-700',
- !canPerformGitOps && 'opacity-50 cursor-not-allowed'
- )}
- >
-
- Merge to Main
- {!canPerformGitOps && (
-
- )}
-
-
- )}
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
@@ -303,6 +359,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">
@@ -310,6 +437,13 @@ export function WorktreeActionsDropdown({
)}
+
+ {worktree.hasChanges && (
+ onViewChanges(worktree)} className="text-xs">
+
+ View Changes
+
+ )}
{worktree.hasChanges && (
>
)}
+
+ {worktree.hasChanges && (
+
+ gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
+ disabled={!gitRepoStatus.isGitRepo}
+ className={cn(
+ 'text-xs text-destructive focus:text-destructive',
+ !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
+ )}
+ >
+
+ Discard Changes
+ {!gitRepoStatus.isGitRepo && (
+
+ )}
+
+
+ )}
{!worktree.isMain && (
<>
+
+ canPerformGitOps && onMerge(worktree)}
+ disabled={!canPerformGitOps}
+ className={cn(
+ 'text-xs text-green-600 focus:text-green-700',
+ !canPerformGitOps && 'opacity-50 cursor-not-allowed'
+ )}
+ >
+
+ Merge Branch
+ {!canPerformGitOps && (
+
+ )}
+
+
onDeleteWorktree(worktree)}
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..d8a57ced 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,8 +1,10 @@
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 { useDroppable } from '@dnd-kit/core';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -27,7 +29,10 @@ interface WorktreeTabProps {
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
+ hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
+ /** Whether auto mode is running for this worktree */
+ isAutoModeRunning?: boolean;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -36,7 +41,12 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
+ onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
+ onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
+ onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
+ onViewChanges: (worktree: WorktreeInfo) => void;
+ onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -48,6 +58,7 @@ interface WorktreeTabProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
+ onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -71,7 +82,9 @@ export function WorktreeTab({
isStartingDevServer,
aheadCount,
behindCount,
+ hasRemoteBranch,
gitRepoStatus,
+ isAutoModeRunning = false,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -80,7 +93,12 @@ export function WorktreeTab({
onCreateBranch,
onPull,
onPush,
+ onPushNewBranch,
onOpenInEditor,
+ onOpenInIntegratedTerminal,
+ onOpenInExternalTerminal,
+ onViewChanges,
+ onDiscardChanges,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -92,8 +110,19 @@ export function WorktreeTab({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
+ onToggleAutoMode,
hasInitScript,
}: WorktreeTabProps) {
+ // Make the worktree tab a drop target for feature cards
+ const { setNodeRef, isOver } = useDroppable({
+ id: `worktree-drop-${worktree.branch}`,
+ data: {
+ type: 'worktree',
+ branch: worktree.branch,
+ path: worktree.path,
+ isMain: worktree.isMain,
+ },
+ });
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
@@ -180,7 +209,13 @@ export function WorktreeTab({
}
return (
-
+
{worktree.isMain ? (
<>
- {isRunning && }
- {isActivating && !isRunning && }
+ {isRunning && }
+ {isActivating && !isRunning && }
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
@@ -264,8 +299,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
- {isRunning && }
- {isActivating && !isRunning && }
+ {isRunning && }
+ {isActivating && !isRunning && }
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
@@ -327,21 +362,48 @@ export function WorktreeTab({
)}
+ {isAutoModeRunning && (
+
+
+
+
+
+
+
+
+ Auto Mode Running
+
+
+
+ )}
+
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts
index a3db9750..1d184c73 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts
+++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts
@@ -1,65 +1,46 @@
-import { useState, useEffect, useCallback, useMemo } from 'react';
-import { createLogger } from '@automaker/utils/logger';
+import { useMemo, useCallback } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
+import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
import type { EditorInfo } from '@automaker/types';
-const logger = createLogger('AvailableEditors');
-
// Re-export EditorInfo for convenience
export type { EditorInfo };
+/**
+ * Hook for fetching and managing available editors
+ *
+ * Uses React Query for data fetching with caching.
+ * Provides a refresh function that clears server cache and re-detects editors.
+ */
export function useAvailableEditors() {
- const [editors, setEditors] = useState
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [isRefreshing, setIsRefreshing] = useState(false);
-
- const fetchAvailableEditors = useCallback(async () => {
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.getAvailableEditors) {
- setIsLoading(false);
- return;
- }
- const result = await api.worktree.getAvailableEditors();
- if (result.success && result.result?.editors) {
- setEditors(result.result.editors);
- }
- } catch (error) {
- logger.error('Failed to fetch available editors:', error);
- } finally {
- setIsLoading(false);
- }
- }, []);
+ const queryClient = useQueryClient();
+ const { data: editors = [], isLoading } = useAvailableEditorsQuery();
/**
- * Refresh editors by clearing the server cache and re-detecting
+ * Mutation to refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
- const refresh = useCallback(async () => {
- setIsRefreshing(true);
- try {
+ const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
+ mutationFn: async () => {
const api = getElectronAPI();
- if (!api?.worktree?.refreshEditors) {
- // Fallback to regular fetch if refresh not available
- await fetchAvailableEditors();
- return;
- }
const result = await api.worktree.refreshEditors();
- if (result.success && result.result?.editors) {
- setEditors(result.result.editors);
- logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to refresh editors');
}
- } catch (error) {
- logger.error('Failed to refresh editors:', error);
- } finally {
- setIsRefreshing(false);
- }
- }, [fetchAvailableEditors]);
+ return result.result?.editors ?? [];
+ },
+ onSuccess: (newEditors) => {
+ // Update the cache with new editors
+ queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
+ },
+ });
- useEffect(() => {
- fetchAvailableEditors();
- }, [fetchAvailableEditors]);
+ const refresh = useCallback(() => {
+ refreshMutate();
+ }, [refreshMutate]);
return {
editors,
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-branches.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts
index 1cb1cec6..c6ba6207 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts
+++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts
@@ -1,66 +1,46 @@
import { useState, useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { getElectronAPI } from '@/lib/electron';
-import type { BranchInfo, GitRepoStatus } from '../types';
-
-const logger = createLogger('Branches');
+import { useWorktreeBranches } from '@/hooks/queries';
+import type { GitRepoStatus } from '../types';
+/**
+ * Hook for managing branch data with React Query
+ *
+ * Uses useWorktreeBranches for data fetching while maintaining
+ * the current interface for backward compatibility. Tracks which
+ * worktree path is currently being viewed and fetches branches on demand.
+ */
export function useBranches() {
- const [branches, setBranches] = useState([]);
- const [aheadCount, setAheadCount] = useState(0);
- const [behindCount, setBehindCount] = useState(0);
- const [isLoadingBranches, setIsLoadingBranches] = useState(false);
+ const [currentWorktreePath, setCurrentWorktreePath] = useState();
const [branchFilter, setBranchFilter] = useState('');
- const [gitRepoStatus, setGitRepoStatus] = useState({
- isGitRepo: true,
- hasCommits: true,
- });
- /** Helper to reset branch state to initial values */
- const resetBranchState = useCallback(() => {
- setBranches([]);
- setAheadCount(0);
- setBehindCount(0);
- }, []);
+ const {
+ data: branchData,
+ isLoading: isLoadingBranches,
+ refetch,
+ } = useWorktreeBranches(currentWorktreePath);
+
+ const branches = branchData?.branches ?? [];
+ const aheadCount = branchData?.aheadCount ?? 0;
+ const behindCount = branchData?.behindCount ?? 0;
+ const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
+ // Use conservative defaults (false) until data is confirmed
+ // This prevents the UI from assuming git capabilities before the query completes
+ const gitRepoStatus: GitRepoStatus = {
+ isGitRepo: branchData?.isGitRepo ?? false,
+ hasCommits: branchData?.hasCommits ?? false,
+ };
const fetchBranches = useCallback(
- async (worktreePath: string) => {
- setIsLoadingBranches(true);
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.listBranches) {
- logger.warn('List branches API not available');
- return;
- }
- const result = await api.worktree.listBranches(worktreePath);
- if (result.success && result.result) {
- setBranches(result.result.branches);
- setAheadCount(result.result.aheadCount || 0);
- setBehindCount(result.result.behindCount || 0);
- setGitRepoStatus({ isGitRepo: true, hasCommits: true });
- } else if (result.code === 'NOT_GIT_REPO') {
- // Not a git repository - clear branches silently without logging an error
- resetBranchState();
- setGitRepoStatus({ isGitRepo: false, hasCommits: false });
- } else if (result.code === 'NO_COMMITS') {
- // Git repo but no commits yet - clear branches silently without logging an error
- resetBranchState();
- setGitRepoStatus({ isGitRepo: true, hasCommits: false });
- } else if (!result.success) {
- // Other errors - log them
- logger.warn('Failed to fetch branches:', result.error);
- resetBranchState();
- }
- } catch (error) {
- logger.error('Failed to fetch branches:', error);
- resetBranchState();
- // Reset git status to unknown state on network/API errors
- setGitRepoStatus({ isGitRepo: true, hasCommits: true });
- } finally {
- setIsLoadingBranches(false);
+ (worktreePath: string) => {
+ if (worktreePath === currentWorktreePath) {
+ // Same path - just refetch to get latest data
+ refetch();
+ } else {
+ // Different path - update the tracked path (triggers new query)
+ setCurrentWorktreePath(worktreePath);
}
},
- [resetBranchState]
+ [currentWorktreePath, refetch]
);
const resetBranchFilter = useCallback(() => {
@@ -76,6 +56,7 @@ export function useBranches() {
filteredBranches,
aheadCount,
behindCount,
+ hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
index 82a5a814..b00de694 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
+++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-running-features.ts
@@ -17,6 +17,8 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
// Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
+ // Check if branch names match - this handles both main worktree (any primary branch name)
+ // and feature worktrees
return worktree.branch === feature.branchName;
}
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..b089fdf4 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,157 +1,112 @@
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';
+import {
+ useSwitchBranch,
+ usePullWorktree,
+ usePushWorktree,
+ useOpenInEditor,
+} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
-// Error codes that need special user-friendly handling
-const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
-type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
-
-// User-friendly messages for git status errors
-const GIT_STATUS_ERROR_MESSAGES: Record = {
- NOT_GIT_REPO: 'This directory is not a git repository',
- NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
-};
-
-/**
- * Helper to handle git status errors with user-friendly messages.
- * @returns true if the error was a git status error and was handled, false otherwise.
- */
-function handleGitStatusError(result: { code?: string; error?: string }): boolean {
- const errorCode = result.code as GitStatusErrorCode | undefined;
- if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
- toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
- return true;
- }
- return false;
-}
-
-interface UseWorktreeActionsOptions {
- fetchWorktrees: () => Promise | undefined>;
- fetchBranches: (worktreePath: string) => Promise;
-}
-
-export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
- const [isPulling, setIsPulling] = useState(false);
- const [isPushing, setIsPushing] = useState(false);
- const [isSwitching, setIsSwitching] = useState(false);
+export function useWorktreeActions() {
+ const navigate = useNavigate();
const [isActivating, setIsActivating] = useState(false);
+ // Use React Query mutations
+ const switchBranchMutation = useSwitchBranch();
+ const pullMutation = usePullWorktree();
+ const pushMutation = usePushWorktree();
+ const openInEditorMutation = useOpenInEditor();
+
const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => {
- if (isSwitching || branchName === worktree.branch) return;
- setIsSwitching(true);
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.switchBranch) {
- toast.error('Switch branch API not available');
- return;
- }
- const result = await api.worktree.switchBranch(worktree.path, branchName);
- if (result.success && result.result) {
- toast.success(result.result.message);
- fetchWorktrees();
- } else {
- if (handleGitStatusError(result)) return;
- toast.error(result.error || 'Failed to switch branch');
- }
- } catch (error) {
- logger.error('Switch branch failed:', error);
- toast.error('Failed to switch branch');
- } finally {
- setIsSwitching(false);
- }
+ if (switchBranchMutation.isPending || branchName === worktree.branch) return;
+ switchBranchMutation.mutate({
+ worktreePath: worktree.path,
+ branchName,
+ });
},
- [isSwitching, fetchWorktrees]
+ [switchBranchMutation]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
- if (isPulling) return;
- setIsPulling(true);
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.pull) {
- toast.error('Pull API not available');
- return;
- }
- const result = await api.worktree.pull(worktree.path);
- if (result.success && result.result) {
- toast.success(result.result.message);
- fetchWorktrees();
- } else {
- if (handleGitStatusError(result)) return;
- toast.error(result.error || 'Failed to pull latest changes');
- }
- } catch (error) {
- logger.error('Pull failed:', error);
- toast.error('Failed to pull latest changes');
- } finally {
- setIsPulling(false);
- }
+ if (pullMutation.isPending) return;
+ pullMutation.mutate(worktree.path);
},
- [isPulling, fetchWorktrees]
+ [pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
- if (isPushing) return;
- setIsPushing(true);
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.push) {
- toast.error('Push API not available');
- return;
- }
- const result = await api.worktree.push(worktree.path);
- if (result.success && result.result) {
- toast.success(result.result.message);
- fetchBranches(worktree.path);
- fetchWorktrees();
- } else {
- if (handleGitStatusError(result)) return;
- toast.error(result.error || 'Failed to push changes');
- }
- } catch (error) {
- logger.error('Push failed:', error);
- toast.error('Failed to push changes');
- } finally {
- setIsPushing(false);
- }
+ if (pushMutation.isPending) return;
+ pushMutation.mutate({
+ worktreePath: worktree.path,
+ });
},
- [isPushing, fetchBranches, fetchWorktrees]
+ [pushMutation]
);
- const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.openInEditor) {
- logger.warn('Open in editor API not available');
- return;
+ 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) => {
+ openInEditorMutation.mutate({
+ worktreePath: worktree.path,
+ editorCommand,
+ });
+ },
+ [openInEditorMutation]
+ );
+
+ 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);
}
- const result = await api.worktree.openInEditor(worktree.path, editorCommand);
- if (result.success && result.result) {
- toast.success(result.result.message);
- } else if (result.error) {
- toast.error(result.error);
- }
- } catch (error) {
- logger.error('Open in editor failed:', error);
- }
- }, []);
+ },
+ []
+ );
return {
- isPulling,
- isPushing,
- isSwitching,
+ isPulling: pullMutation.isPending,
+ isPushing: pushMutation.isPending,
+ isSwitching: switchBranchMutation.isPending,
isActivating,
setIsActivating,
handleSwitchBranch,
handlePull,
handlePush,
+ handleOpenInIntegratedTerminal,
handleOpenInEditor,
+ handleOpenInExternalTerminal,
};
}
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
index 95589f4b..6a3276ec 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
+++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts
@@ -1,12 +1,11 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { createLogger } from '@automaker/utils/logger';
+import { useEffect, useCallback, useRef } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
-import { getElectronAPI } from '@/lib/electron';
+import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
import { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
-const logger = createLogger('Worktrees');
-
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
@@ -18,62 +17,46 @@ export function useWorktrees({
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
- const [isLoading, setIsLoading] = useState(false);
- const [worktrees, setWorktrees] = useState([]);
+ const queryClient = useQueryClient();
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
- const fetchWorktrees = useCallback(
- async (options?: { silent?: boolean }) => {
- if (!projectPath) return;
- const silent = options?.silent ?? false;
- if (!silent) {
- setIsLoading(true);
- }
- try {
- const api = getElectronAPI();
- if (!api?.worktree?.listAll) {
- logger.warn('Worktree API not available');
- return;
- }
- // Pass forceRefreshGitHub when this is a manual refresh (not silent polling)
- // This clears the GitHub remote cache so users can re-detect after adding a remote
- const forceRefreshGitHub = !silent;
- const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub);
- if (result.success && result.worktrees) {
- setWorktrees(result.worktrees);
- setWorktreesInStore(projectPath, result.worktrees);
- }
- // Return removed worktrees so they can be handled by the caller
- return result.removedWorktrees;
- } catch (error) {
- logger.error('Failed to fetch worktrees:', error);
- return undefined;
- } finally {
- if (!silent) {
- setIsLoading(false);
- }
- }
- },
- [projectPath, setWorktreesInStore]
- );
+ // Use the React Query hook
+ const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
+ const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
+ // Sync worktrees to Zustand store when they change
useEffect(() => {
- fetchWorktrees();
- }, [fetchWorktrees]);
+ if (worktrees.length > 0) {
+ setWorktreesInStore(projectPath, worktrees);
+ }
+ }, [worktrees, projectPath, setWorktreesInStore]);
+ // Handle removed worktrees callback when data changes
+ const prevRemovedWorktreesRef = useRef(null);
+ useEffect(() => {
+ if (data?.removedWorktrees && data.removedWorktrees.length > 0) {
+ // Create a stable key to avoid duplicate callbacks
+ const key = JSON.stringify(data.removedWorktrees);
+ if (key !== prevRemovedWorktreesRef.current) {
+ prevRemovedWorktreesRef.current = key;
+ onRemovedWorktrees?.(data.removedWorktrees);
+ }
+ }
+ }, [data?.removedWorktrees, onRemovedWorktrees]);
+
+ // Handle refresh trigger
useEffect(() => {
if (refreshTrigger > 0) {
- fetchWorktrees().then((removedWorktrees) => {
- if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
- onRemovedWorktrees(removedWorktrees);
- }
+ // Invalidate and refetch to get fresh data including any removed worktrees
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.worktrees.all(projectPath),
});
}
- }, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
+ }, [refreshTrigger, projectPath, queryClient]);
// Use a ref to track the current worktree to avoid running validation
// when selection changes (which could cause a race condition with stale worktrees list)
@@ -111,6 +94,14 @@ export function useWorktrees({
[projectPath, setCurrentWorktree]
);
+ // fetchWorktrees for backward compatibility - now just triggers a refetch
+ const fetchWorktrees = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.worktrees.all(projectPath),
+ });
+ return refetch();
+ }, [projectPath, queryClient, refetch]);
+
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
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..4ccb3634 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<{
@@ -64,6 +61,12 @@ export interface PRInfo {
}>;
}
+export interface MergeConflictInfo {
+ sourceBranch: string;
+ targetBranch: string;
+ targetWorktreePath: string;
+}
+
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -73,7 +76,9 @@ export interface WorktreePanelProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
- onMerge: (worktree: WorktreeInfo) => void;
+ onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
+ /** Called when a branch is deleted during merge - features should be reassigned to main */
+ onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];
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..cb645ea6 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,10 +1,12 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
-import { cn, pathsEqual } from '@/lib/utils';
+import { Spinner } from '@/components/ui/spinner';
+import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
+import { useWorktreeInitScript } from '@/hooks/queries';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -20,6 +22,11 @@ import {
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
+import { useAppStore } from '@/store/app-store';
+import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
+import { ConfirmDialog } from '@/components/ui/confirm-dialog';
+import { Undo2 } from 'lucide-react';
+import { getElectronAPI } from '@/lib/electron';
export function WorktreePanel({
projectPath,
@@ -30,7 +37,8 @@ export function WorktreePanel({
onCreateBranch,
onAddressPRComments,
onResolveConflicts,
- onMerge,
+ onCreateMergeConflictResolutionFeature,
+ onBranchDeletedDuringMerge,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
@@ -49,7 +57,6 @@ export function WorktreePanel({
const {
isStartingDevServer,
- getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
@@ -62,6 +69,7 @@ export function WorktreePanel({
filteredBranches,
aheadCount,
behindCount,
+ hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,
@@ -78,42 +86,100 @@ export function WorktreePanel({
handleSwitchBranch,
handlePull,
handlePush,
+ handleOpenInIntegratedTerminal,
handleOpenInEditor,
- } = useWorktreeActions({
- fetchWorktrees,
- fetchBranches,
- });
+ handleOpenInExternalTerminal,
+ } = useWorktreeActions();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
features,
});
- // Track whether init script exists for the project
- const [hasInitScript, setHasInitScript] = useState(false);
+ // Auto-mode state management using the store
+ // Use separate selectors to avoid creating new object references on each render
+ const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
+ const currentProject = useAppStore((state) => state.currentProject);
+
+ // Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
+ const getAutoModeWorktreeKey = useCallback(
+ (projectId: string, branchName: string | null): string => {
+ return `${projectId}::${branchName ?? '__main__'}`;
+ },
+ []
+ );
+
+ // Helper to check if auto-mode is running for a specific worktree
+ const isAutoModeRunningForWorktree = useCallback(
+ (worktree: WorktreeInfo): boolean => {
+ if (!currentProject) return false;
+ const branchName = worktree.isMain ? null : worktree.branch;
+ const key = getAutoModeWorktreeKey(currentProject.id, branchName);
+ return autoModeByWorktree[key]?.isRunning ?? false;
+ },
+ [currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
+ );
+
+ // Handler to toggle auto-mode for a worktree
+ const handleToggleAutoMode = useCallback(
+ async (worktree: WorktreeInfo) => {
+ if (!currentProject) return;
+
+ // Import the useAutoMode to get start/stop functions
+ // Since useAutoMode is a hook, we'll use the API client directly
+ const api = getHttpApiClient();
+ const branchName = worktree.isMain ? null : worktree.branch;
+ const isRunning = isAutoModeRunningForWorktree(worktree);
+
+ try {
+ if (isRunning) {
+ const result = await api.autoMode.stop(projectPath, branchName);
+ if (result.success) {
+ const desc = branchName ? `worktree ${branchName}` : 'main branch';
+ toast.success(`Auto Mode stopped for ${desc}`);
+ } else {
+ toast.error(result.error || 'Failed to stop Auto Mode');
+ }
+ } else {
+ const result = await api.autoMode.start(projectPath, branchName);
+ if (result.success) {
+ const desc = branchName ? `worktree ${branchName}` : 'main branch';
+ toast.success(`Auto Mode started for ${desc}`);
+ } else {
+ toast.error(result.error || 'Failed to start Auto Mode');
+ }
+ }
+ } catch (error) {
+ toast.error('Error toggling Auto Mode');
+ console.error('Auto mode toggle error:', error);
+ }
+ },
+ [currentProject, projectPath, isAutoModeRunningForWorktree]
+ );
+
+ // Check if init script exists for the project using React Query
+ const { data: initScriptData } = useWorktreeInitScript(projectPath);
+ const hasInitScript = initScriptData?.exists ?? false;
+
+ // View changes dialog state
+ const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
+ const [viewChangesWorktree, setViewChangesWorktree] = useState(null);
+
+ // Discard changes confirmation dialog state
+ const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
+ const [discardChangesWorktree, setDiscardChangesWorktree] = useState(null);
// Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState(null);
- useEffect(() => {
- if (!projectPath) {
- setHasInitScript(false);
- return;
- }
+ // Push to remote dialog state
+ const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
+ const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState(null);
- const checkInitScript = async () => {
- try {
- const api = getHttpApiClient();
- const result = await api.worktree.getInitScript(projectPath);
- setHasInitScript(result.success && result.exists);
- } catch {
- setHasInitScript(false);
- }
- };
-
- checkInitScript();
- }, [projectPath]);
+ // Merge branch dialog state
+ const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
+ const [mergeWorktree, setMergeWorktree] = useState(null);
const isMobile = useIsMobile();
@@ -178,6 +244,41 @@ export function WorktreePanel({
[projectPath]
);
+ const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
+ setViewChangesWorktree(worktree);
+ setViewChangesDialogOpen(true);
+ }, []);
+
+ const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
+ setDiscardChangesWorktree(worktree);
+ setDiscardChangesDialogOpen(true);
+ }, []);
+
+ const handleConfirmDiscardChanges = useCallback(async () => {
+ if (!discardChangesWorktree) return;
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.discardChanges(discardChangesWorktree.path);
+
+ if (result.success) {
+ toast.success('Changes discarded', {
+ description: `Discarded changes in ${discardChangesWorktree.branch}`,
+ });
+ // Refresh worktrees to update the changes status
+ fetchWorktrees({ silent: true });
+ } else {
+ toast.error('Failed to discard changes', {
+ description: result.error || 'Unknown error',
+ });
+ }
+ } catch (error) {
+ toast.error('Failed to discard changes', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }, [discardChangesWorktree, fetchWorktrees]);
+
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -190,6 +291,54 @@ export function WorktreePanel({
// Keep logPanelWorktree set for smooth close animation
}, []);
+ // Handle opening the push to remote dialog
+ const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
+ setPushToRemoteWorktree(worktree);
+ setPushToRemoteDialogOpen(true);
+ }, []);
+
+ // Handle confirming the push to remote dialog
+ const handleConfirmPushToRemote = useCallback(
+ async (worktree: WorktreeInfo, remote: string) => {
+ try {
+ const api = getElectronAPI();
+ if (!api?.worktree?.push) {
+ toast.error('Push API not available');
+ return;
+ }
+ const result = await api.worktree.push(worktree.path, false, remote);
+ if (result.success && result.result) {
+ toast.success(result.result.message);
+ fetchBranches(worktree.path);
+ fetchWorktrees();
+ } else {
+ toast.error(result.error || 'Failed to push changes');
+ }
+ } catch (error) {
+ toast.error('Failed to push changes');
+ }
+ },
+ [fetchBranches, fetchWorktrees]
+ );
+
+ // Handle opening the merge dialog
+ const handleMerge = useCallback((worktree: WorktreeInfo) => {
+ setMergeWorktree(worktree);
+ setMergeDialogOpen(true);
+ }, []);
+
+ // Handle merge completion - refresh worktrees and reassign features if branch was deleted
+ const handleMerged = useCallback(
+ (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
+ fetchWorktrees();
+ // If the branch was deleted, notify parent to reassign features to main
+ if (deletedBranch && onBranchDeletedDuringMerge) {
+ onBranchDeletedDuringMerge(mergedWorktree.branch);
+ }
+ },
+ [fetchWorktrees, onBranchDeletedDuringMerge]
+ );
+
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -235,27 +384,35 @@ export function WorktreePanel({
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
+ hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
+ isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
+ onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
+ onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
+ onOpenInExternalTerminal={handleOpenInExternalTerminal}
+ onViewChanges={handleViewChanges}
+ onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
- onMerge={onMerge}
+ onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
+ onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -285,10 +442,58 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
-
+ {isLoading ? : }
>
)}
+
+ {/* View Changes Dialog */}
+
+
+ {/* Discard Changes Confirmation Dialog */}
+
+
+ {/* Dev Server Logs Panel */}
+
+
+ {/* Push to Remote Dialog */}
+
+
+ {/* Merge Branch Dialog */}
+
);
}
@@ -322,7 +527,9 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
+ hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
+ isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -331,18 +538,24 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
+ onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
+ onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
+ onOpenInExternalTerminal={handleOpenInExternalTerminal}
+ onViewChanges={handleViewChanges}
+ onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
- onMerge={onMerge}
+ onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
+ onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -380,7 +593,9 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
+ hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
+ isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
@@ -389,18 +604,24 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
+ onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
+ onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
+ onOpenInExternalTerminal={handleOpenInExternalTerminal}
+ onViewChanges={handleViewChanges}
+ onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
- onMerge={onMerge}
+ onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
+ onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
);
@@ -429,12 +650,33 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
-
+ {isLoading ? : }
>
)}
+ {/* View Changes Dialog */}
+
+
+ {/* Discard Changes Confirmation Dialog */}
+
+
{/* Dev Server Logs Panel */}
+
+ {/* Push to Remote Dialog */}
+
+
+ {/* Merge Branch Dialog */}
+
);
}
diff --git a/apps/ui/src/components/views/chat-history.tsx b/apps/ui/src/components/views/chat-history.tsx
index e6939361..eed0b062 100644
--- a/apps/ui/src/components/views/chat-history.tsx
+++ b/apps/ui/src/components/views/chat-history.tsx
@@ -1,5 +1,7 @@
-import { useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { UIEvent } from 'react';
import { useAppStore } from '@/store/app-store';
+import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -22,6 +24,10 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
+const CHAT_SESSION_ROW_HEIGHT_PX = 84;
+const CHAT_SESSION_OVERSCAN_COUNT = 6;
+const CHAT_SESSION_LIST_PADDING_PX = 8;
+
export function ChatHistory() {
const {
chatSessions,
@@ -34,29 +40,117 @@ export function ChatHistory() {
unarchiveChatSession,
deleteChatSession,
setChatHistoryOpen,
- } = useAppStore();
+ } = useAppStore(
+ useShallow((state) => ({
+ chatSessions: state.chatSessions,
+ currentProject: state.currentProject,
+ currentChatSession: state.currentChatSession,
+ chatHistoryOpen: state.chatHistoryOpen,
+ createChatSession: state.createChatSession,
+ setCurrentChatSession: state.setCurrentChatSession,
+ archiveChatSession: state.archiveChatSession,
+ unarchiveChatSession: state.unarchiveChatSession,
+ deleteChatSession: state.deleteChatSession,
+ setChatHistoryOpen: state.setChatHistoryOpen,
+ }))
+ );
const [searchQuery, setSearchQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
+ const listRef = useRef(null);
+ const scrollRafRef = useRef(null);
+ const [scrollTop, setScrollTop] = useState(0);
+ const [viewportHeight, setViewportHeight] = useState(0);
- if (!currentProject) {
- return null;
- }
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const currentProjectId = currentProject?.id;
// Filter sessions for current project
- const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id);
+ const projectSessions = useMemo(() => {
+ if (!currentProjectId) return [];
+ return chatSessions.filter((session) => session.projectId === currentProjectId);
+ }, [chatSessions, currentProjectId]);
// Filter by search query and archived status
- const filteredSessions = projectSessions.filter((session) => {
- const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
- const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
- return matchesSearch && matchesArchivedStatus;
- });
+ const filteredSessions = useMemo(() => {
+ return projectSessions.filter((session) => {
+ const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
+ const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
+ return matchesSearch && matchesArchivedStatus;
+ });
+ }, [projectSessions, normalizedQuery, showArchived]);
// Sort by most recently updated
- const sortedSessions = filteredSessions.sort(
- (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ const sortedSessions = useMemo(() => {
+ return [...filteredSessions].sort(
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ );
+ }, [filteredSessions]);
+
+ const totalHeight =
+ sortedSessions.length * CHAT_SESSION_ROW_HEIGHT_PX + CHAT_SESSION_LIST_PADDING_PX * 2;
+ const startIndex = Math.max(
+ 0,
+ Math.floor(scrollTop / CHAT_SESSION_ROW_HEIGHT_PX) - CHAT_SESSION_OVERSCAN_COUNT
);
+ const endIndex = Math.min(
+ sortedSessions.length,
+ Math.ceil((scrollTop + viewportHeight) / CHAT_SESSION_ROW_HEIGHT_PX) +
+ CHAT_SESSION_OVERSCAN_COUNT
+ );
+ const offsetTop = startIndex * CHAT_SESSION_ROW_HEIGHT_PX;
+ const visibleSessions = sortedSessions.slice(startIndex, endIndex);
+
+ const handleScroll = useCallback((event: UIEvent) => {
+ const target = event.currentTarget;
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ scrollRafRef.current = requestAnimationFrame(() => {
+ setScrollTop(target.scrollTop);
+ scrollRafRef.current = null;
+ });
+ }, []);
+
+ useEffect(() => {
+ const container = listRef.current;
+ if (!container || typeof window === 'undefined') return;
+
+ const updateHeight = () => {
+ setViewportHeight(container.clientHeight);
+ };
+
+ updateHeight();
+
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', updateHeight);
+ return () => window.removeEventListener('resize', updateHeight);
+ }
+
+ const observer = new ResizeObserver(() => updateHeight());
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [chatHistoryOpen]);
+
+ useEffect(() => {
+ if (!chatHistoryOpen) return;
+ setScrollTop(0);
+ if (listRef.current) {
+ listRef.current.scrollTop = 0;
+ }
+ }, [chatHistoryOpen, normalizedQuery, showArchived, currentProjectId]);
+
+ useEffect(() => {
+ return () => {
+ if (scrollRafRef.current !== null) {
+ cancelAnimationFrame(scrollRafRef.current);
+ }
+ };
+ }, []);
+
+ if (!currentProjectId) {
+ return null;
+ }
const handleCreateNewChat = () => {
createChatSession();
@@ -151,7 +245,11 @@ export function ChatHistory() {
{/* Chat Sessions List */}
-
+
{sortedSessions.length === 0 ? (
{searchQuery ? (
@@ -163,60 +261,75 @@ export function ChatHistory() {
)}
) : (
-
- {sortedSessions.map((session) => (
-
handleSelectSession(session)}
- >
-
-
{session.title}
-
- {session.messages.length} messages
-
-
- {new Date(session.updatedAt).toLocaleDateString()}
-
-
+
+
+ {visibleSessions.map((session) => (
+
handleSelectSession(session)}
+ >
+
+
{session.title}
+
+ {session.messages.length} messages
+
+
+ {new Date(session.updatedAt).toLocaleDateString()}
+
+
-
-
-
-
-
-
-
-
- {session.archived ? (
+
+
+
+
+
+
+
+
+ {session.archived ? (
+ handleUnarchiveSession(session.id, e)}
+ >
+
+ Unarchive
+
+ ) : (
+ handleArchiveSession(session.id, e)}
+ >
+
+ Archive
+
+ )}
+
handleUnarchiveSession(session.id, e)}
+ onClick={(e) => handleDeleteSession(session.id, e)}
+ className="text-destructive"
>
-
- Unarchive
+
+ Delete
- ) : (
- handleArchiveSession(session.id, e)}>
-
- Archive
-
- )}
-
- handleDeleteSession(session.id, e)}
- className="text-destructive"
- >
-
- Delete
-
-
-
+
+
+
-
- ))}
+ ))}
+
)}
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.tsx b/apps/ui/src/components/views/github-issues-view.tsx
index 0ae6e1e8..986ad65c 100644
--- a/apps/ui/src/components/views/github-issues-view.tsx
+++ b/apps/ui/src/components/views/github-issues-view.tsx
@@ -2,6 +2,7 @@
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
+import { useQueryClient } from '@tanstack/react-query';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -10,6 +11,7 @@ import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { toast } from 'sonner';
+import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
@@ -36,6 +38,7 @@ export function GitHubIssuesView() {
const [filterState, setFilterState] = useState
(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
+ const queryClient = useQueryClient();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
@@ -153,6 +156,10 @@ export function GitHubIssuesView() {
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
+ // Invalidate React Query cache to sync UI
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(currentProject.path),
+ });
toast.success(`Created task: ${issue.title}`);
} else {
toast.error(result.error || 'Failed to create task');
@@ -163,7 +170,7 @@ export function GitHubIssuesView() {
toast.error(err instanceof Error ? err.message : 'Failed to create task');
}
},
- [currentProject?.path, currentBranch]
+ [currentProject?.path, currentBranch, queryClient]
);
if (loading) {
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-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts
index 0083a877..a97667f1 100644
--- a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts
+++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts
@@ -1,79 +1,29 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { getElectronAPI, GitHubIssue } from '@/lib/electron';
+/**
+ * GitHub Issues Hook
+ *
+ * React Query-based hook for fetching GitHub issues.
+ */
-const logger = createLogger('GitHubIssues');
import { useAppStore } from '@/store/app-store';
+import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries';
export function useGithubIssues() {
const { currentProject } = useAppStore();
- const [openIssues, setOpenIssues] = useState
([]);
- const [closedIssues, setClosedIssues] = useState([]);
- const [loading, setLoading] = useState(true);
- const [refreshing, setRefreshing] = useState(false);
- const [error, setError] = useState(null);
- const isMountedRef = useRef(true);
- const fetchIssues = useCallback(async () => {
- if (!currentProject?.path) {
- if (isMountedRef.current) {
- setError('No project selected');
- setLoading(false);
- }
- return;
- }
-
- try {
- if (isMountedRef.current) {
- setError(null);
- }
- const api = getElectronAPI();
- if (api.github) {
- const result = await api.github.listIssues(currentProject.path);
- if (isMountedRef.current) {
- if (result.success) {
- setOpenIssues(result.openIssues || []);
- setClosedIssues(result.closedIssues || []);
- } else {
- setError(result.error || 'Failed to fetch issues');
- }
- }
- }
- } catch (err) {
- if (isMountedRef.current) {
- logger.error('Error fetching issues:', err);
- setError(err instanceof Error ? err.message : 'Failed to fetch issues');
- }
- } finally {
- if (isMountedRef.current) {
- setLoading(false);
- setRefreshing(false);
- }
- }
- }, [currentProject?.path]);
-
- useEffect(() => {
- isMountedRef.current = true;
- fetchIssues();
-
- return () => {
- isMountedRef.current = false;
- };
- }, [fetchIssues]);
-
- const refresh = useCallback(() => {
- if (isMountedRef.current) {
- setRefreshing(true);
- }
- fetchIssues();
- }, [fetchIssues]);
+ const {
+ data,
+ isLoading: loading,
+ isFetching: refreshing,
+ error,
+ refetch: refresh,
+ } = useGitHubIssuesQuery(currentProject?.path);
return {
- openIssues,
- closedIssues,
+ openIssues: data?.openIssues ?? [],
+ closedIssues: data?.closedIssues ?? [],
loading,
refreshing,
- error,
+ error: error instanceof Error ? error.message : error ? String(error) : null,
refresh,
};
}
diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts
index 7ae1b130..44f36ac8 100644
--- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts
+++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts
@@ -1,9 +1,7 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { getElectronAPI, GitHubComment } from '@/lib/electron';
-
-const logger = createLogger('IssueComments');
+import { useMemo, useCallback } from 'react';
+import type { GitHubComment } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
+import { useGitHubIssueComments } from '@/hooks/queries';
interface UseIssueCommentsResult {
comments: GitHubComment[];
@@ -18,119 +16,36 @@ interface UseIssueCommentsResult {
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
const { currentProject } = useAppStore();
- const [comments, setComments] = useState([]);
- const [totalCount, setTotalCount] = useState(0);
- const [loading, setLoading] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
- const [hasNextPage, setHasNextPage] = useState(false);
- const [endCursor, setEndCursor] = useState(undefined);
- const [error, setError] = useState(null);
- const isMountedRef = useRef(true);
- const fetchComments = useCallback(
- async (cursor?: string) => {
- if (!currentProject?.path || !issueNumber) {
- return;
- }
+ // Use React Query infinite query
+ const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } =
+ useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined);
- const isLoadingMore = !!cursor;
+ // Flatten all pages into a single comments array
+ const comments = useMemo(() => {
+ return data?.pages.flatMap((page) => page.comments) ?? [];
+ }, [data?.pages]);
- try {
- if (isMountedRef.current) {
- setError(null);
- if (isLoadingMore) {
- setLoadingMore(true);
- } else {
- setLoading(true);
- }
- }
-
- const api = getElectronAPI();
- if (api.github) {
- const result = await api.github.getIssueComments(
- currentProject.path,
- issueNumber,
- cursor
- );
-
- if (isMountedRef.current) {
- if (result.success) {
- if (isLoadingMore) {
- // Append new comments
- setComments((prev) => [...prev, ...(result.comments || [])]);
- } else {
- // Replace all comments
- setComments(result.comments || []);
- }
- setTotalCount(result.totalCount || 0);
- setHasNextPage(result.hasNextPage || false);
- setEndCursor(result.endCursor);
- } else {
- setError(result.error || 'Failed to fetch comments');
- }
- }
- }
- } catch (err) {
- if (isMountedRef.current) {
- logger.error('Error fetching comments:', err);
- setError(err instanceof Error ? err.message : 'Failed to fetch comments');
- }
- } finally {
- if (isMountedRef.current) {
- setLoading(false);
- setLoadingMore(false);
- }
- }
- },
- [currentProject?.path, issueNumber]
- );
-
- // Reset and fetch when issue changes
- useEffect(() => {
- isMountedRef.current = true;
-
- if (issueNumber) {
- // Reset state when issue changes
- setComments([]);
- setTotalCount(0);
- setHasNextPage(false);
- setEndCursor(undefined);
- setError(null);
- fetchComments();
- } else {
- // Clear comments when no issue is selected
- setComments([]);
- setTotalCount(0);
- setHasNextPage(false);
- setEndCursor(undefined);
- setLoading(false);
- setError(null);
- }
-
- return () => {
- isMountedRef.current = false;
- };
- }, [issueNumber, fetchComments]);
+ // Get total count from the first page
+ const totalCount = data?.pages[0]?.totalCount ?? 0;
const loadMore = useCallback(() => {
- if (hasNextPage && endCursor && !loadingMore) {
- fetchComments(endCursor);
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
}
- }, [hasNextPage, endCursor, loadingMore, fetchComments]);
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const refresh = useCallback(() => {
- setComments([]);
- setEndCursor(undefined);
- fetchComments();
- }, [fetchComments]);
+ refetch();
+ }, [refetch]);
return {
comments,
totalCount,
- loading,
- loadingMore,
- hasNextPage,
- error,
+ loading: isLoading,
+ loadingMore: isFetchingNextPage,
+ hasNextPage: hasNextPage ?? false,
+ error: error instanceof Error ? error.message : null,
loadMore,
refresh,
};
diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts
index c09baab0..788a9efe 100644
--- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts
+++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts
@@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
+import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
const logger = createLogger('IssueValidation');
@@ -46,6 +47,10 @@ export function useIssueValidation({
new Map()
);
const audioRef = useRef(null);
+
+ // React Query mutations
+ const validateIssueMutation = useValidateIssue(currentProject?.path ?? '');
+ const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? '');
// Refs for stable event handler (avoids re-subscribing on state changes)
const selectedIssueRef = useRef(null);
const showValidationDialogRef = useRef(false);
@@ -240,7 +245,7 @@ export function useIssueValidation({
}
// Check if already validating this issue
- if (validatingIssues.has(issue.number)) {
+ if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) {
toast.info(`Validation already in progress for issue #${issue.number}`);
return;
}
@@ -254,11 +259,6 @@ export function useIssueValidation({
return;
}
- // Start async validation in background (no dialog - user will see badge when done)
- toast.info(`Starting validation for issue #${issue.number}`, {
- description: 'You will be notified when the analysis is complete',
- });
-
// Use provided model override or fall back to phaseModels.validationModel
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
const effectiveModelEntry = modelEntry
@@ -276,40 +276,22 @@ export function useIssueValidation({
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
- try {
- const api = getElectronAPI();
- if (api.github?.validateIssue) {
- const validationInput = {
- issueNumber: issue.number,
- issueTitle: issue.title,
- issueBody: issue.body || '',
- issueLabels: issue.labels.map((l) => l.name),
- comments, // Include comments if provided
- linkedPRs, // Include linked PRs if provided
- };
- const result = await api.github.validateIssue(
- currentProject.path,
- validationInput,
- modelToUse,
- thinkingLevelToUse,
- reasoningEffortToUse
- );
-
- if (!result.success) {
- toast.error(result.error || 'Failed to start validation');
- }
- // On success, the result will come through the event stream
- }
- } catch (err) {
- logger.error('Validation error:', err);
- toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
- }
+ // Use mutation to trigger validation (toast is handled by mutation)
+ validateIssueMutation.mutate({
+ issue,
+ model: modelToUse,
+ thinkingLevel: thinkingLevelToUse,
+ reasoningEffort: reasoningEffortToUse,
+ comments,
+ linkedPRs,
+ });
},
[
currentProject?.path,
validatingIssues,
cachedValidations,
phaseModels.validationModel,
+ validateIssueMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -325,10 +307,8 @@ export function useIssueValidation({
// Mark as viewed if not already viewed
if (!cached.viewedAt && currentProject?.path) {
- try {
- const api = getElectronAPI();
- if (api.github?.markValidationViewed) {
- await api.github.markValidationViewed(currentProject.path, issue.number);
+ markViewedMutation.mutate(issue.number, {
+ onSuccess: () => {
// Update local state
setCachedValidations((prev) => {
const next = new Map(prev);
@@ -341,16 +321,15 @@ export function useIssueValidation({
}
return next;
});
- }
- } catch (err) {
- logger.error('Failed to mark validation as viewed:', err);
- }
+ },
+ });
}
}
},
[
cachedValidations,
currentProject?.path,
+ markViewedMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -361,5 +340,6 @@ export function useIssueValidation({
cachedValidations,
handleValidateIssue,
handleViewCachedValidation,
+ isValidating: validateIssueMutation.isPending,
};
}
diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx
index 855d136c..0a9b3417 100644
--- a/apps/ui/src/components/views/github-prs-view.tsx
+++ b/apps/ui/src/components/views/github-prs-view.tsx
@@ -1,59 +1,37 @@
-import { useState, useEffect, useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
-import { getElectronAPI, GitHubPR } from '@/lib/electron';
+/**
+ * GitHub PRs View
+ *
+ * Displays pull requests using React Query for data fetching.
+ */
+
+import { useState, useCallback } from 'react';
+import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+import { getElectronAPI, type GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
-
-const logger = createLogger('GitHubPRsView');
+import { useGitHubPRs } from '@/hooks/queries';
export function GitHubPRsView() {
- const [openPRs, setOpenPRs] = useState([]);
- const [mergedPRs, setMergedPRs] = useState([]);
- const [loading, setLoading] = useState(true);
- const [refreshing, setRefreshing] = useState(false);
- const [error, setError] = useState(null);
const [selectedPR, setSelectedPR] = useState(null);
const { currentProject } = useAppStore();
- const fetchPRs = useCallback(async () => {
- if (!currentProject?.path) {
- setError('No project selected');
- setLoading(false);
- return;
- }
+ const {
+ data,
+ isLoading: loading,
+ isFetching: refreshing,
+ error,
+ refetch,
+ } = useGitHubPRs(currentProject?.path);
- try {
- setError(null);
- const api = getElectronAPI();
- if (api.github) {
- const result = await api.github.listPRs(currentProject.path);
- if (result.success) {
- setOpenPRs(result.openPRs || []);
- setMergedPRs(result.mergedPRs || []);
- } else {
- setError(result.error || 'Failed to fetch pull requests');
- }
- }
- } catch (err) {
- logger.error('Error fetching PRs:', err);
- setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- }, [currentProject?.path]);
-
- useEffect(() => {
- fetchPRs();
- }, [fetchPRs]);
+ const openPRs = data?.openPRs ?? [];
+ const mergedPRs = data?.mergedPRs ?? [];
const handleRefresh = useCallback(() => {
- setRefreshing(true);
- fetchPRs();
- }, [fetchPRs]);
+ refetch();
+ }, [refetch]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
@@ -86,7 +64,7 @@ export function GitHubPRsView() {
if (loading) {
return (
-
+
);
}
@@ -98,7 +76,9 @@ export function GitHubPRsView() {
Failed to Load Pull Requests
- {error}
+
+ {error instanceof Error ? error.message : 'Failed to fetch pull requests'}
+
Try Again
@@ -134,7 +114,7 @@ export function GitHubPRsView() {
-
+ {refreshing ? : }
@@ -196,9 +176,9 @@ export function GitHubPRsView() {
{selectedPR.state === 'MERGED' ? (
-
+
) : (
-
+
)}
#{selectedPR.number} {selectedPR.title}
@@ -209,7 +189,7 @@ export function GitHubPRsView() {
)}
-
+
{pr.state === 'MERGED' ? (
-
+
) : (
-
+
)}
{pr.title}
{pr.isDraft && (
-
+
Draft
)}
@@ -401,7 +381,7 @@ function PRRow({
{
e.stopPropagation();
onOpenExternal();
diff --git a/apps/ui/src/components/views/graph-view-page.tsx b/apps/ui/src/components/views/graph-view-page.tsx
index f8e9ba0a..96dffb9a 100644
--- a/apps/ui/src/components/views/graph-view-page.tsx
+++ b/apps/ui/src/components/views/graph-view-page.tsx
@@ -1,6 +1,7 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
+import { useShallow } from 'zustand/react/shallow';
import { GraphView } from './graph-view';
import {
EditFeatureDialog,
@@ -17,7 +18,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';
@@ -40,7 +41,20 @@ export function GraphViewPage() {
addFeatureUseSelectedWorktreeBranch,
planUseSelectedWorktreeBranch,
setPlanUseSelectedWorktreeBranch,
- } = useAppStore();
+ } = useAppStore(
+ useShallow((state) => ({
+ currentProject: state.currentProject,
+ updateFeature: state.updateFeature,
+ getCurrentWorktree: state.getCurrentWorktree,
+ getWorktrees: state.getWorktrees,
+ setWorktrees: state.setWorktrees,
+ setCurrentWorktree: state.setCurrentWorktree,
+ defaultSkipTests: state.defaultSkipTests,
+ addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
+ planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
+ setPlanUseSelectedWorktreeBranch: state.setPlanUseSelectedWorktreeBranch,
+ }))
+ );
// Ensure worktrees are loaded when landing directly on graph view
useWorktrees({ projectPath: currentProject?.path ?? '' });
@@ -330,7 +344,7 @@ export function GraphViewPage() {
if (isLoading) {
return (
-
+
);
}
@@ -418,6 +432,7 @@ export function GraphViewPage() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
+ branchName={outputFeature?.branchName}
/>
{/* Backlog Plan Dialog */}
diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx
index 8ad385b9..44cac85c 100644
--- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx
+++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx
@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
import { Trash2 } from 'lucide-react';
+import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
+ renderMode?: GraphRenderMode;
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
@@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const isHighlighted = edgeData?.isHighlighted ?? false;
const isDimmed = edgeData?.isDimmed ?? false;
+ const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
const edgeColor = isHighlighted
? 'var(--brand-500)'
@@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
}
};
+ if (isCompact) {
+ return (
+ <>
+
+ {selected && edgeData?.onDeleteDependency && (
+
+
+
+
+
+
+
+ )}
+ >
+ );
+ }
+
return (
<>
{/* Invisible wider path for hover detection */}
diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx
index 020b1914..16cf6817 100644
--- a/apps/ui/src/components/views/graph-view/components/task-node.tsx
+++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx
@@ -18,6 +18,7 @@ import {
Trash2,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
+import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
- const glassmorphism = data.cardGlassmorphism ?? true;
+ const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
+ const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT;
+ const glassmorphism = shouldUseGlassmorphism && !isCompact;
// Get the border color based on status and error state
const borderColor = data.error
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Get computed border style
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
+ if (isCompact) {
+ return (
+ <>
+
+
+
+
+
+
+ {config.label}
+ {priorityConf && (
+
+ {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
+
+ )}
+
+
+
+ {data.title || data.description}
+
+ {data.title && data.description && (
+
+ {data.description}
+
+ )}
+ {data.isRunning && (
+
+
+ Running
+
+ )}
+ {isStopped && (
+
+
+ Paused
+
+ )}
+
+
+
+
+ >
+ );
+ }
+
return (
<>
{/* Target handle (left side - receives dependencies) */}
diff --git a/apps/ui/src/components/views/graph-view/constants.ts b/apps/ui/src/components/views/graph-view/constants.ts
new file mode 100644
index 00000000..d75b6ea8
--- /dev/null
+++ b/apps/ui/src/components/views/graph-view/constants.ts
@@ -0,0 +1,7 @@
+export const GRAPH_RENDER_MODE_FULL = 'full';
+export const GRAPH_RENDER_MODE_COMPACT = 'compact';
+
+export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT;
+
+export const GRAPH_LARGE_NODE_COUNT = 150;
+export const GRAPH_LARGE_EDGE_COUNT = 300;
diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx
index f14f3120..1286a745 100644
--- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx
+++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, useEffect, useRef } from 'react';
+import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
+import {
+ GRAPH_LARGE_EDGE_COUNT,
+ GRAPH_LARGE_NODE_COUNT,
+ GRAPH_RENDER_MODE_COMPACT,
+ GRAPH_RENDER_MODE_FULL,
+} from './constants';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -198,6 +204,17 @@ function GraphCanvasInner({
// Calculate filter results
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
+ const estimatedEdgeCount = useMemo(() => {
+ return features.reduce((total, feature) => {
+ const deps = feature.dependencies as string[] | undefined;
+ return total + (deps?.length ?? 0);
+ }, 0);
+ }, [features]);
+
+ const isLargeGraph =
+ features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT;
+ const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL;
+
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
@@ -205,6 +222,8 @@ function GraphCanvasInner({
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
+ renderMode,
+ enableEdgeAnimations: !isLargeGraph,
});
// Apply layout
@@ -457,6 +476,8 @@ function GraphCanvasInner({
}
}, []);
+ const shouldRenderVisibleOnly = isLargeGraph;
+
return (
diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx
index 245894ab..e84bb1d5 100644
--- a/apps/ui/src/components/views/graph-view/graph-view.tsx
+++ b/apps/ui/src/components/views/graph-view/graph-view.tsx
@@ -51,7 +51,7 @@ export function GraphView({
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: GraphViewProps) {
- const { currentProject } = useAppStore();
+ const currentProject = useAppStore((state) => state.currentProject);
// Use the same background hook as the board view
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts
index 8349bff6..e769e4e3 100644
--- a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts
+++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts
@@ -54,16 +54,40 @@ function getAncestors(
/**
* Traverses down to find all descendants (features that depend on this one)
*/
-function getDescendants(featureId: string, features: Feature[], visited: Set): void {
+function getDescendants(
+ featureId: string,
+ dependentsMap: Map,
+ visited: Set
+): void {
if (visited.has(featureId)) return;
visited.add(featureId);
+ const dependents = dependentsMap.get(featureId);
+ if (!dependents || dependents.length === 0) return;
+
+ for (const dependentId of dependents) {
+ getDescendants(dependentId, dependentsMap, visited);
+ }
+}
+
+function buildDependentsMap(features: Feature[]): Map {
+ const dependentsMap = new Map();
+
for (const feature of features) {
const deps = feature.dependencies as string[] | undefined;
- if (deps?.includes(featureId)) {
- getDescendants(feature.id, features, visited);
+ if (!deps || deps.length === 0) continue;
+
+ for (const depId of deps) {
+ const existing = dependentsMap.get(depId);
+ if (existing) {
+ existing.push(feature.id);
+ } else {
+ dependentsMap.set(depId, [feature.id]);
+ }
}
}
+
+ return dependentsMap;
}
/**
@@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set, features: Feature[
* Gets the effective status of a feature (accounting for running state)
* Treats completed (archived) as verified
*/
-function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
+function getEffectiveStatus(feature: Feature, runningTaskIds: Set): StatusFilterValue {
if (feature.status === 'in_progress') {
- return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
+ return runningTaskIds.has(feature.id) ? 'running' : 'paused';
}
// Treat completed (archived) as verified
if (feature.status === 'completed') {
@@ -119,6 +143,7 @@ export function useGraphFilter(
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
+ const runningTaskIds = new Set(runningAutoTasks);
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
@@ -139,6 +164,7 @@ export function useGraphFilter(
// Find directly matched nodes
const matchedNodeIds = new Set();
const featureMap = new Map(features.map((f) => [f.id, f]));
+ const dependentsMap = buildDependentsMap(features);
for (const feature of features) {
let matchesSearch = true;
@@ -159,7 +185,7 @@ export function useGraphFilter(
// Check status match
if (hasStatusFilter) {
- const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
+ const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
@@ -190,7 +216,7 @@ export function useGraphFilter(
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
- getDescendants(id, features, highlightedNodeIds);
+ getDescendants(id, dependentsMap, highlightedNodeIds);
}
// Get edges in the highlighted path
diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts
index 3e9e41e0..3b902611 100644
--- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts
+++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
-import { getBlockingDependencies } from '@automaker/dependency-resolver';
+import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver';
+import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants';
import { GraphFilterResult } from './use-graph-filter';
export interface TaskNodeData extends Feature {
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
onResumeTask?: () => void;
onSpawnTask?: () => void;
onDeleteTask?: () => void;
+ renderMode?: GraphRenderMode;
}
export type TaskNode = Node;
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
+ renderMode?: GraphRenderMode;
}>;
export interface NodeActionCallbacks {
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
+ renderMode?: GraphRenderMode;
+ enableEdgeAnimations?: boolean;
}
/**
@@ -78,14 +83,14 @@ export function useGraphNodes({
filterResult,
actionCallbacks,
backgroundSettings,
+ renderMode = GRAPH_RENDER_MODE_FULL,
+ enableEdgeAnimations = true,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
- const featureMap = new Map();
-
- // Create feature map for quick lookups
- features.forEach((f) => featureMap.set(f.id, f));
+ const featureMap = createFeatureMap(features);
+ const runningTaskIds = new Set(runningAutoTasks);
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
@@ -95,8 +100,8 @@ export function useGraphNodes({
// Create nodes
features.forEach((feature) => {
- const isRunning = runningAutoTasks.includes(feature.id);
- const blockingDeps = getBlockingDependencies(feature, features);
+ const isRunning = runningTaskIds.has(feature.id);
+ const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
@@ -121,6 +126,7 @@ export function useGraphNodes({
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
+ renderMode,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -166,13 +172,14 @@ export function useGraphNodes({
source: depId,
target: feature.id,
type: 'dependency',
- animated: isRunning || runningAutoTasks.includes(depId),
+ animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
onDeleteDependency: actionCallbacks?.onDeleteDependency,
+ renderMode,
},
};
edgeList.push(edge);
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..a402b8d1 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,12 +3,13 @@
*/
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';
import { useAppStore } from '@/store/app-store';
-import { getElectronAPI } from '@/lib/electron';
+import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
@@ -27,6 +28,9 @@ export function PromptList({ category, onBack }: PromptListProps) {
const [loadingPromptId, setLoadingPromptId] = useState
(null);
const [startedPrompts, setStartedPrompts] = useState>(new Set());
const navigate = useNavigate();
+
+ // React Query mutation
+ const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
const {
getPromptsByCategory,
isLoading: isLoadingPrompts,
@@ -56,7 +60,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
return;
}
- if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
+ if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
setLoadingPromptId(prompt.id);
@@ -68,42 +72,31 @@ export function PromptList({ category, onBack }: PromptListProps) {
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
- try {
- const api = getElectronAPI();
- const result = await api.ideation?.generateSuggestions(
- currentProject.path,
- prompt.id,
- category
- );
-
- if (result?.success && result.suggestions) {
- updateJobStatus(jobId, 'ready', result.suggestions);
- toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
- duration: 10000,
- action: {
- label: 'View Ideas',
- onClick: () => {
- setMode('dashboard');
- navigate({ to: '/ideation' });
+ generateMutation.mutate(
+ { promptId: prompt.id, category },
+ {
+ onSuccess: (data) => {
+ updateJobStatus(jobId, 'ready', data.suggestions);
+ toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
+ duration: 10000,
+ action: {
+ label: 'View Ideas',
+ onClick: () => {
+ setMode('dashboard');
+ navigate({ to: '/ideation' });
+ },
},
- },
- });
- } else {
- updateJobStatus(
- jobId,
- 'error',
- undefined,
- result?.error || 'Failed to generate suggestions'
- );
- toast.error(result?.error || 'Failed to generate suggestions');
+ });
+ setLoadingPromptId(null);
+ },
+ onError: (error) => {
+ console.error('Failed to generate suggestions:', error);
+ updateJobStatus(jobId, 'error', undefined, error.message);
+ toast.error(error.message);
+ setLoadingPromptId(null);
+ },
}
- } catch (error) {
- console.error('Failed to generate suggestions:', error);
- updateJobStatus(jobId, 'error', undefined, (error as Error).message);
- toast.error((error as Error).message);
- } finally {
- setLoadingPromptId(null);
- }
+ );
};
return (
@@ -121,7 +114,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
{isLoadingPrompts && (
-
+
Loading prompts...
)}
@@ -162,7 +155,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/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts
index 7f052ef5..e29564d1 100644
--- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts
+++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts
@@ -1,5 +1,5 @@
import type { LucideIcon } from 'lucide-react';
-import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
+import { User, GitBranch, Palette, AlertTriangle, Workflow } from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem {
@@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'theme', label: 'Theme', icon: Palette },
+ { id: 'claude', label: 'Models', icon: Workflow },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];
diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
index 19faf5e3..89cb87bc 100644
--- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
+++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
-export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
+export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
interface UseProjectSettingsViewOptions {
initialView?: ProjectSettingsViewId;
diff --git a/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx
new file mode 100644
index 00000000..66e2cb0e
--- /dev/null
+++ b/apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx
@@ -0,0 +1,356 @@
+import { useState, useMemo } from 'react';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { Project } from '@/lib/electron';
+import type {
+ PhaseModelKey,
+ PhaseModelEntry,
+ ClaudeCompatibleProvider,
+ ClaudeModelAlias,
+} from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
+
+interface ProjectBulkReplaceDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: Project;
+}
+
+// Phase display names for preview
+const PHASE_LABELS: Record
= {
+ enhancementModel: 'Feature Enhancement',
+ fileDescriptionModel: 'File Descriptions',
+ imageDescriptionModel: 'Image Descriptions',
+ commitMessageModel: 'Commit Messages',
+ validationModel: 'GitHub Issue Validation',
+ specGenerationModel: 'App Specification',
+ featureGenerationModel: 'Feature Generation',
+ backlogPlanningModel: 'Backlog Planning',
+ projectAnalysisModel: 'Project Analysis',
+ suggestionsModel: 'AI Suggestions',
+ memoryExtractionModel: 'Memory Extraction',
+};
+
+const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
+
+// Claude model display names
+const CLAUDE_MODEL_DISPLAY: Record = {
+ haiku: 'Claude Haiku',
+ sonnet: 'Claude Sonnet',
+ opus: 'Claude Opus',
+};
+
+export function ProjectBulkReplaceDialog({
+ open,
+ onOpenChange,
+ project,
+}: ProjectBulkReplaceDialogProps) {
+ const { phaseModels, setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
+ const [selectedProvider, setSelectedProvider] = useState('anthropic');
+
+ // Get project-level overrides
+ const projectOverrides = project.phaseModelOverrides || {};
+
+ // Get enabled providers
+ const enabledProviders = useMemo(() => {
+ return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
+ }, [claudeCompatibleProviders]);
+
+ // Build provider options for the dropdown
+ const providerOptions = useMemo(() => {
+ const options: Array<{ id: string; name: string; isNative: boolean }> = [
+ { id: 'anthropic', name: 'Anthropic Direct', isNative: true },
+ ];
+
+ enabledProviders.forEach((provider) => {
+ options.push({
+ id: provider.id,
+ name: provider.name,
+ isNative: false,
+ });
+ });
+
+ return options;
+ }, [enabledProviders]);
+
+ // Get the selected provider config (if custom)
+ const selectedProviderConfig = useMemo(() => {
+ if (selectedProvider === 'anthropic') return null;
+ return enabledProviders.find((p) => p.id === selectedProvider);
+ }, [selectedProvider, enabledProviders]);
+
+ // Get the Claude model alias from a PhaseModelEntry
+ const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
+ // Check if model string directly matches a Claude alias
+ if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
+ if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
+ if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
+
+ // If it's a provider model, look up the mapping
+ if (entry.providerId) {
+ const provider = enabledProviders.find((p) => p.id === entry.providerId);
+ if (provider) {
+ const model = provider.models?.find((m) => m.id === entry.model);
+ if (model?.mapsToClaudeModel) {
+ return model.mapsToClaudeModel;
+ }
+ }
+ }
+
+ // Default to sonnet
+ return 'sonnet';
+ };
+
+ // Find the model from provider that maps to a specific Claude model
+ const findModelForClaudeAlias = (
+ provider: ClaudeCompatibleProvider | null,
+ claudeAlias: ClaudeModelAlias,
+ phase: PhaseModelKey
+ ): PhaseModelEntry => {
+ if (!provider) {
+ // Anthropic Direct - reset to default phase model (includes correct thinking levels)
+ return DEFAULT_PHASE_MODELS[phase];
+ }
+
+ // Find model that maps to this Claude alias
+ const models = provider.models || [];
+ const match = models.find((m) => m.mapsToClaudeModel === claudeAlias);
+
+ if (match) {
+ return { providerId: provider.id, model: match.id };
+ }
+
+ // Fallback: use first model if no match
+ if (models.length > 0) {
+ return { providerId: provider.id, model: models[0].id };
+ }
+
+ // Ultimate fallback to native Claude model
+ return { model: claudeAlias };
+ };
+
+ // Generate preview of changes
+ const preview = useMemo(() => {
+ return ALL_PHASES.map((phase) => {
+ // Current effective value (project override or global)
+ const globalEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
+ const currentEntry = projectOverrides[phase] || globalEntry;
+ const claudeAlias = getClaudeModelAlias(currentEntry);
+ const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
+
+ // Get display names
+ const getCurrentDisplay = (): string => {
+ if (currentEntry.providerId) {
+ const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
+ if (provider) {
+ const model = provider.models?.find((m) => m.id === currentEntry.model);
+ return model?.displayName || currentEntry.model;
+ }
+ }
+ return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
+ };
+
+ const getNewDisplay = (): string => {
+ if (newEntry.providerId && selectedProviderConfig) {
+ const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
+ return model?.displayName || newEntry.model;
+ }
+ return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
+ };
+
+ const isChanged =
+ currentEntry.model !== newEntry.model ||
+ currentEntry.providerId !== newEntry.providerId ||
+ currentEntry.thinkingLevel !== newEntry.thinkingLevel;
+
+ return {
+ phase,
+ label: PHASE_LABELS[phase],
+ claudeAlias,
+ currentDisplay: getCurrentDisplay(),
+ newDisplay: getNewDisplay(),
+ newEntry,
+ isChanged,
+ };
+ });
+ }, [phaseModels, projectOverrides, selectedProviderConfig, enabledProviders]);
+
+ // Count how many will change
+ const changeCount = preview.filter((p) => p.isChanged).length;
+
+ // Apply the bulk replace as project overrides
+ const handleApply = () => {
+ preview.forEach(({ phase, newEntry, isChanged }) => {
+ if (isChanged) {
+ setProjectPhaseModelOverride(project.id, phase, newEntry);
+ }
+ });
+ onOpenChange(false);
+ };
+
+ // Check if provider has all 3 Claude model mappings
+ const providerModelCoverage = useMemo(() => {
+ if (selectedProvider === 'anthropic') {
+ return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true };
+ }
+ if (!selectedProviderConfig) {
+ return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false };
+ }
+ const models = selectedProviderConfig.models || [];
+ const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku');
+ const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet');
+ const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus');
+ return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus };
+ }, [selectedProvider, selectedProviderConfig]);
+
+ const providerHasModels =
+ selectedProvider === 'anthropic' ||
+ (selectedProviderConfig && selectedProviderConfig.models?.length > 0);
+
+ return (
+
+
+
+ Bulk Replace Models (Project Override)
+
+ Set project-level overrides for all phases to use models from a specific provider. This
+ only affects this project.
+
+
+
+
+ {/* Provider selector */}
+
+
Target Provider
+
+
+
+
+
+ {providerOptions.map((option) => (
+
+
+ {option.isNative ? (
+
+ ) : (
+
+ )}
+ {option.name}
+
+
+ ))}
+
+
+
+
+ {/* Warning if provider has no models */}
+ {!providerHasModels && (
+
+
+
+
This provider has no models configured.
+
+
+ )}
+
+ {/* Warning if provider doesn't have all 3 mappings */}
+ {providerHasModels && !providerModelCoverage.complete && (
+
+
+
+
+ This provider is missing mappings for:{' '}
+ {[
+ !providerModelCoverage.hasHaiku && 'Haiku',
+ !providerModelCoverage.hasSonnet && 'Sonnet',
+ !providerModelCoverage.hasOpus && 'Opus',
+ ]
+ .filter(Boolean)
+ .join(', ')}
+
+
+
+ )}
+
+ {/* Preview of changes */}
+ {providerHasModels && (
+
+
+ Preview Changes
+
+ {changeCount} of {ALL_PHASES.length} will be overridden
+
+
+
+
+
+
+ Phase
+ Current
+
+
+ New Override
+
+
+
+
+ {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
+
+ {label}
+ {currentDisplay}
+
+ {isChanged ? (
+
+ ) : (
+
+ )}
+
+
+
+ {newDisplay}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Apply Overrides ({changeCount})
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx
new file mode 100644
index 00000000..ceb13b73
--- /dev/null
+++ b/apps/ui/src/components/views/project-settings-view/project-claude-section.tsx
@@ -0,0 +1,151 @@
+import { useAppStore } from '@/store/app-store';
+import { useSetupStore } from '@/store/setup-store';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Bot, Cloud, Server, Globe } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { Project } from '@/lib/electron';
+
+interface ProjectClaudeSectionProps {
+ project: Project;
+}
+
+export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
+ const {
+ claudeApiProfiles,
+ activeClaudeApiProfileId: globalActiveProfileId,
+ disabledProviders,
+ setProjectClaudeApiProfile,
+ } = useAppStore();
+ const { claudeAuthStatus } = useSetupStore();
+
+ // Get project-level override from project
+ const projectActiveProfileId = project.activeClaudeApiProfileId;
+
+ // Determine effective value for display
+ // undefined = use global, null = explicit direct, string = specific profile
+ const selectValue =
+ projectActiveProfileId === undefined
+ ? 'global'
+ : projectActiveProfileId === null
+ ? 'direct'
+ : projectActiveProfileId;
+
+ // Check if Claude is available
+ const isClaudeDisabled = disabledProviders.includes('claude');
+ const hasProfiles = claudeApiProfiles.length > 0;
+ const isClaudeAuthenticated = claudeAuthStatus?.authenticated;
+
+ // Get global profile name for display
+ const globalProfile = globalActiveProfileId
+ ? claudeApiProfiles.find((p) => p.id === globalActiveProfileId)
+ : null;
+ const globalProfileName = globalProfile?.name || 'Direct Anthropic API';
+
+ const handleChange = (value: string) => {
+ // 'global' -> undefined (use global)
+ // 'direct' -> null (explicit direct)
+ // profile id -> string (specific profile)
+ const newValue = value === 'global' ? undefined : value === 'direct' ? null : value;
+ setProjectClaudeApiProfile(project.id, newValue);
+ };
+
+ // Don't render if Claude is disabled or not available
+ if (isClaudeDisabled || (!hasProfiles && !isClaudeAuthenticated)) {
+ return (
+
+
+
Claude not configured
+
+ Enable Claude and configure providers in global settings to use per-project overrides.
+
+
+ );
+ }
+
+ // Get the display text for current selection
+ const getDisplayText = () => {
+ if (selectValue === 'global') {
+ return `Using global setting: ${globalProfileName}`;
+ }
+ if (selectValue === 'direct') {
+ return 'Using direct Anthropic API (API key or Claude Max plan)';
+ }
+ const selectedProfile = claudeApiProfiles.find((p) => p.id === selectValue);
+ return `Using ${selectedProfile?.name || 'custom'} endpoint`;
+ };
+
+ return (
+
+
+
+
+
+
+
Claude Provider
+
+
+ Override the Claude provider for this project only.
+
+
+
+
+
+
Active Provider for This Project
+
+
+
+
+
+
+
+
+ Use Global Setting
+ ({globalProfileName})
+
+
+
+
+
+ Direct Anthropic API
+
+
+ {claudeApiProfiles.map((profile) => (
+
+
+
+ {profile.name}
+
+
+ ))}
+
+
+
{getDisplayText()}
+
+
+ {/* Info about what this affects */}
+
+
This setting affects all Claude operations for this project including:
+
+ Agent chat and feature implementation
+ Code analysis and suggestions
+ Commit message generation
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx
new file mode 100644
index 00000000..809439c1
--- /dev/null
+++ b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx
@@ -0,0 +1,365 @@
+import { useState } from 'react';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import { Workflow, RotateCcw, Globe, Check, Replace } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { Project } from '@/lib/electron';
+import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
+import { ProjectBulkReplaceDialog } from './project-bulk-replace-dialog';
+import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
+
+interface ProjectModelsSectionProps {
+ project: Project;
+}
+
+interface PhaseConfig {
+ key: PhaseModelKey;
+ label: string;
+ description: string;
+}
+
+const QUICK_TASKS: PhaseConfig[] = [
+ {
+ key: 'enhancementModel',
+ label: 'Feature Enhancement',
+ description: 'Improves feature names and descriptions',
+ },
+ {
+ key: 'fileDescriptionModel',
+ label: 'File Descriptions',
+ description: 'Generates descriptions for context files',
+ },
+ {
+ key: 'imageDescriptionModel',
+ label: 'Image Descriptions',
+ description: 'Analyzes and describes context images',
+ },
+ {
+ key: 'commitMessageModel',
+ label: 'Commit Messages',
+ description: 'Generates git commit messages from diffs',
+ },
+];
+
+const VALIDATION_TASKS: PhaseConfig[] = [
+ {
+ key: 'validationModel',
+ label: 'GitHub Issue Validation',
+ description: 'Validates and improves GitHub issues',
+ },
+];
+
+const GENERATION_TASKS: PhaseConfig[] = [
+ {
+ key: 'specGenerationModel',
+ label: 'App Specification',
+ description: 'Generates full application specifications',
+ },
+ {
+ key: 'featureGenerationModel',
+ label: 'Feature Generation',
+ description: 'Creates features from specifications',
+ },
+ {
+ key: 'backlogPlanningModel',
+ label: 'Backlog Planning',
+ description: 'Reorganizes and prioritizes backlog',
+ },
+ {
+ key: 'projectAnalysisModel',
+ label: 'Project Analysis',
+ description: 'Analyzes project structure for suggestions',
+ },
+ {
+ key: 'suggestionsModel',
+ label: 'AI Suggestions',
+ description: 'Model for feature, refactoring, security, and performance suggestions',
+ },
+];
+
+const MEMORY_TASKS: PhaseConfig[] = [
+ {
+ key: 'memoryExtractionModel',
+ label: 'Memory Extraction',
+ description: 'Extracts learnings from completed agent sessions',
+ },
+];
+
+const ALL_PHASES = [...QUICK_TASKS, ...VALIDATION_TASKS, ...GENERATION_TASKS, ...MEMORY_TASKS];
+
+function PhaseOverrideItem({
+ phase,
+ project,
+ globalValue,
+ projectOverride,
+}: {
+ phase: PhaseConfig;
+ project: Project;
+ globalValue: PhaseModelEntry;
+ projectOverride?: PhaseModelEntry;
+}) {
+ const { setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore();
+
+ const hasOverride = !!projectOverride;
+ const effectiveValue = projectOverride || globalValue;
+
+ // Get display name for a model
+ const getModelDisplayName = (entry: PhaseModelEntry): string => {
+ if (entry.providerId) {
+ const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId);
+ if (provider) {
+ const model = provider.models?.find((m) => m.id === entry.model);
+ if (model) {
+ return `${model.displayName} (${provider.name})`;
+ }
+ }
+ }
+ // Default to model ID for built-in models (both short aliases and canonical IDs)
+ const modelMap: Record = {
+ haiku: 'Claude Haiku',
+ sonnet: 'Claude Sonnet',
+ opus: 'Claude Opus',
+ 'claude-haiku': 'Claude Haiku',
+ 'claude-sonnet': 'Claude Sonnet',
+ 'claude-opus': 'Claude Opus',
+ };
+ return modelMap[entry.model] || entry.model;
+ };
+
+ const handleClearOverride = () => {
+ setProjectPhaseModelOverride(project.id, phase.key, null);
+ };
+
+ const handleSetOverride = (entry: PhaseModelEntry) => {
+ setProjectPhaseModelOverride(project.id, phase.key, entry);
+ };
+
+ return (
+
+
+
+
{phase.label}
+ {hasOverride ? (
+
+ Override
+
+ ) : (
+
+
+ Global
+
+ )}
+
+
{phase.description}
+ {hasOverride && (
+
+ Using: {getModelDisplayName(effectiveValue)}
+
+ )}
+ {!hasOverride && (
+
+ Using global: {getModelDisplayName(globalValue)}
+
+ )}
+
+
+
+ {hasOverride && (
+
+
+ Reset
+
+ )}
+
+
+
+ );
+}
+
+function PhaseGroup({
+ title,
+ subtitle,
+ phases,
+ project,
+}: {
+ title: string;
+ subtitle: string;
+ phases: PhaseConfig[];
+ project: Project;
+}) {
+ const { phaseModels } = useAppStore();
+ const projectOverrides = project.phaseModelOverrides || {};
+
+ return (
+
+
+
+ {phases.map((phase) => (
+
+ ))}
+
+
+ );
+}
+
+export function ProjectModelsSection({ project }: ProjectModelsSectionProps) {
+ const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } =
+ useAppStore();
+ const [showBulkReplace, setShowBulkReplace] = useState(false);
+
+ // Count how many overrides are set
+ const overrideCount = Object.keys(project.phaseModelOverrides || {}).length;
+
+ // Check if Claude is available
+ const isClaudeDisabled = disabledProviders.includes('claude');
+
+ // Check if there are any enabled ClaudeCompatibleProviders
+ const hasEnabledProviders =
+ claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
+
+ if (isClaudeDisabled) {
+ return (
+
+
+
Claude not configured
+
+ Enable Claude in global settings to configure per-project model overrides.
+
+
+ );
+ }
+
+ const handleClearAll = () => {
+ clearAllProjectPhaseModelOverrides(project.id);
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ Model Overrides
+
+
+ Override AI models for this project only
+
+
+
+
+ {hasEnabledProviders && (
+ setShowBulkReplace(true)}
+ className="gap-2"
+ >
+
+ Bulk Replace
+
+ )}
+ {overrideCount > 0 && (
+
+
+ Reset All ({overrideCount})
+
+ )}
+
+
+
+
+ {/* Bulk Replace Dialog */}
+
+
+ {/* Info Banner */}
+
+
+
+
+ Per-Phase Overrides
+
+ Override specific phases to use different models for this project. Phases without
+ overrides use the global settings.
+
+
+
+ {/* Content */}
+
+ {/* Quick Tasks */}
+
+
+ {/* Validation Tasks */}
+
+
+ {/* Generation Tasks */}
+
+
+ {/* Memory Tasks */}
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx
index b570b1f4..75548f66 100644
--- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx
+++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
+import { ProjectModelsSection } from './project-models-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
@@ -84,6 +85,8 @@ export function ProjectSettingsView() {
return ;
case 'worktrees':
return ;
+ case 'claude':
+ return ;
case 'danger':
return (
-
+
) : (
<>
@@ -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..4265650b 100644
--- a/apps/ui/src/components/views/running-agents-view.tsx
+++ b/apps/ui/src/components/views/running-agents-view.tsx
@@ -1,122 +1,66 @@
-import { useState, useEffect, useCallback } from 'react';
+/**
+ * Running Agents View
+ *
+ * Displays all currently running agents across all projects.
+ * Uses React Query for data fetching with automatic polling.
+ */
+
+import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
-import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
-import { getElectronAPI, RunningAgent } from '@/lib/electron';
+import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
+import { Spinner } from '@/components/ui/spinner';
+import { getElectronAPI, type RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
-
-const logger = createLogger('RunningAgentsView');
+import { useRunningAgents } from '@/hooks/queries';
+import { useStopFeature } from '@/hooks/mutations';
export function RunningAgentsView() {
- const [runningAgents, setRunningAgents] = useState
([]);
- const [loading, setLoading] = useState(true);
- const [refreshing, setRefreshing] = useState(false);
const [selectedAgent, setSelectedAgent] = useState(null);
const { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
- const fetchRunningAgents = useCallback(async () => {
- try {
- const api = getElectronAPI();
- if (api.runningAgents) {
- logger.debug('Fetching running agents list');
- const result = await api.runningAgents.getAll();
- if (result.success && result.runningAgents) {
- logger.debug('Running agents list fetched', {
- count: result.runningAgents.length,
- });
- setRunningAgents(result.runningAgents);
- } else {
- logger.debug('Running agents list fetch returned empty/failed', {
- success: result.success,
- });
- }
- } else {
- logger.debug('Running agents API not available');
- }
- } catch (error) {
- logger.error('Error fetching running agents:', error);
- } finally {
- setLoading(false);
- setRefreshing(false);
- }
- }, []);
+ const logger = createLogger('RunningAgentsView');
- // Initial fetch
- useEffect(() => {
- fetchRunningAgents();
- }, [fetchRunningAgents]);
+ // Use React Query for running agents with auto-refresh
+ const { data, isLoading, isFetching, refetch } = useRunningAgents();
- // Auto-refresh every 2 seconds
- useEffect(() => {
- const interval = setInterval(() => {
- fetchRunningAgents();
- }, 2000);
+ const runningAgents = data?.agents ?? [];
- return () => clearInterval(interval);
- }, [fetchRunningAgents]);
-
- // Subscribe to auto-mode events to update in real-time
- useEffect(() => {
- const api = getElectronAPI();
- if (!api.autoMode) {
- logger.debug('Auto mode API not available for running agents view');
- return;
- }
-
- const unsubscribe = api.autoMode.onEvent((event) => {
- logger.debug('Auto mode event in running agents view', {
- type: event.type,
- });
- // When a feature completes or errors, refresh the list
- if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
- fetchRunningAgents();
- }
- });
-
- return () => {
- unsubscribe();
- };
- }, [fetchRunningAgents]);
+ // Use mutation for stopping features
+ const stopFeature = useStopFeature();
const handleRefresh = useCallback(() => {
- logger.debug('Manual refresh requested for running agents');
- setRefreshing(true);
- fetchRunningAgents();
- }, [fetchRunningAgents]);
+ refetch();
+ }, [refetch]);
const handleStopAgent = useCallback(
async (agent: RunningAgent) => {
- try {
- const api = getElectronAPI();
- const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
- if (isBacklogPlan && api.backlogPlan) {
- logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
+ const api = getElectronAPI();
+ // Handle backlog plans separately - they use a different API
+ const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
+ if (isBacklogPlan && api.backlogPlan) {
+ logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
+ try {
await api.backlogPlan.stop();
- fetchRunningAgents();
- return;
+ } catch (error) {
+ logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error });
+ } finally {
+ refetch();
}
- if (api.autoMode) {
- logger.debug('Stopping running agent', { featureId: agent.featureId });
- await api.autoMode.stopFeature(agent.featureId);
- // Refresh list after stopping
- fetchRunningAgents();
- } else {
- logger.debug('Auto mode API not available to stop agent', { featureId: agent.featureId });
- }
- } catch (error) {
- logger.error('Error stopping agent:', error);
+ return;
}
+ // Use mutation for regular features
+ stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
},
- [fetchRunningAgents]
+ [stopFeature, refetch, logger]
);
const handleNavigateToProject = useCallback(
(agent: RunningAgent) => {
- // Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
logger.debug('Navigating to running agent project', {
@@ -143,10 +87,10 @@ export function RunningAgentsView() {
setSelectedAgent(agent);
}, []);
- if (loading) {
+ if (isLoading) {
return (
-
+
);
}
@@ -168,8 +112,12 @@ export function RunningAgentsView() {
-
-
+
+ {isFetching ? (
+
+ ) : (
+
+ )}
Refresh
@@ -253,7 +201,12 @@ export function RunningAgentsView() {
>
View Project
- handleStopAgent(agent)}>
+ handleStopAgent(agent)}
+ disabled={stopFeature.isPending}
+ >
Stop
@@ -275,6 +228,7 @@ export function RunningAgentsView() {
}
featureId={selectedAgent.featureId}
featureStatus="running"
+ branchName={selectedAgent.branchName}
/>
)}
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..caf745b1 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, Info } 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';
@@ -100,9 +101,37 @@ export function ApiKeysSection() {
- {/* API Key Fields */}
+ {/* API Key Fields with contextual info */}
{providerConfigs.map((provider) => (
-
+
+
+ {/* Anthropic-specific provider info */}
+ {provider.key === 'anthropic' && (
+
+
+
+
+
+
+ Using Claude Compatible Providers?
+ {' '}
+ Add a provider in AI Providers → Claude {' '}
+ with{' '}
+
+ credentials
+ {' '}
+ as the API key source to use this key.
+
+
+ For alternative providers (z.AI GLM, MiniMax, OpenRouter), add a provider with{' '}
+ inline {' '}
+ key source and enter the provider's API key directly.
+
+
+
+
+ )}
+
))}
{/* Security Notice */}
@@ -142,7 +171,7 @@ export function ApiKeysSection() {
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
-
+
) : (
)}
@@ -159,7 +188,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..d2300c88 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
@@ -1,12 +1,11 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useMemo } from 'react';
import { cn } from '@/lib/utils';
-import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
-import { useAppStore } from '@/store/app-store';
+import { useClaudeUsage } from '@/hooks/queries';
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';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
@@ -14,13 +13,10 @@ const CLAUDE_LOGIN_COMMAND = 'claude login';
const CLAUDE_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
-const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
-const REFRESH_INTERVAL_MS = 60_000;
-const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
@@ -80,77 +76,31 @@ function UsageCard({
export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
- const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
- const [error, setError] = useState
(null);
- const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!claudeAuthStatus?.authenticated;
+
+ // Use React Query for data fetching with automatic polling
+ const {
+ data: claudeUsage,
+ isLoading,
+ isFetching,
+ error,
+ dataUpdatedAt,
+ refetch,
+ } = useClaudeUsage(canFetchUsage);
+
// If we have usage data, we can show it even if auth status is unsure
const hasUsage = !!claudeUsage;
- const lastUpdatedLabel = claudeUsageLastUpdated
- ? new Date(claudeUsageLastUpdated).toLocaleString()
- : null;
+ const lastUpdatedLabel = useMemo(() => {
+ return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
+ }, [dataUpdatedAt]);
+
+ const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) ||
- (error && error.includes('Authentication required'));
-
- const isStale =
- !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
-
- const fetchUsage = useCallback(async () => {
- setIsLoading(true);
- setError(null);
- try {
- const api = getElectronAPI();
- if (!api.claude) {
- setError(ERROR_NO_API);
- return;
- }
- const result = await api.claude.getUsage();
-
- if ('error' in result) {
- // Check for auth errors specifically
- if (
- result.message?.includes('Authentication required') ||
- result.error?.includes('Authentication required')
- ) {
- // We'll show the auth warning UI instead of a generic error
- } else {
- setError(result.message || result.error);
- }
- return;
- }
-
- setClaudeUsage(result);
- } catch (fetchError) {
- const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
- setError(message);
- } finally {
- setIsLoading(false);
- }
- }, [setClaudeUsage]);
-
- useEffect(() => {
- // Initial fetch if authenticated and stale
- // Compute staleness inside effect to avoid re-running when Date.now() changes
- const isDataStale =
- !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
- if (canFetchUsage && isDataStale) {
- void fetchUsage();
- }
- }, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
-
- useEffect(() => {
- if (!canFetchUsage) return undefined;
-
- const intervalId = setInterval(() => {
- void fetchUsage();
- }, REFRESH_INTERVAL_MS);
-
- return () => clearInterval(intervalId);
- }, [fetchUsage, canFetchUsage]);
+ (errorMessage && errorMessage.includes('Authentication required'));
return (
refetch()}
+ disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
-
+ {isFetching ? : }
{CLAUDE_USAGE_SUBTITLE}
@@ -194,10 +144,10 @@ export function ClaudeUsageSection() {
)}
- {error && !showAuthWarning && (
+ {errorMessage && !showAuthWarning && (
-
{error}
+
{errorMessage}
)}
@@ -219,7 +169,7 @@ export function ClaudeUsageSection() {
)}
- {!hasUsage && !error && !showAuthWarning && !isLoading && (
+ {!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
{CLAUDE_NO_USAGE_MESSAGE}
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..9836f76e 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,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
+import { SkeletonPulse } from '@/components/ui/skeleton';
+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';
@@ -34,10 +36,6 @@ function getAuthMethodLabel(method: string): string {
}
}
-function SkeletonPulse({ className }: { className?: string }) {
- return
;
-}
-
function ClaudeCliStatusSkeleton() {
return (
-
+ {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..28eb54f2 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,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
+import { SkeletonPulse } from '@/components/ui/skeleton';
+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';
@@ -29,10 +31,6 @@ function getAuthMethodLabel(method: string): string {
}
}
-function SkeletonPulse({ className }: { className?: string }) {
- return
;
-}
-
function CodexCliStatusSkeleton() {
return (
-
+ {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..baac62aa 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,7 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
+import { SkeletonPulse } from '@/components/ui/skeleton';
+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';
@@ -19,10 +21,6 @@ interface CursorCliStatusProps {
onRefresh: () => void;
}
-function SkeletonPulse({ className }: { className?: string }) {
- return
;
-}
-
export function CursorCliStatusSkeleton() {
return (
-
+ {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..ec960083 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,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
+import { SkeletonPulse } from '@/components/ui/skeleton';
+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';
@@ -74,10 +76,6 @@ interface OpencodeCliStatusProps {
onRefresh: () => void;
}
-function SkeletonPulse({ className }: { className?: string }) {
- return
;
-}
-
export function OpencodeCliStatusSkeleton() {
return (
-
+ {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..6d9bf923 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,19 +1,17 @@
-// @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';
-import { getElectronAPI } from '@/lib/electron';
import {
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
} from '@/lib/codex-usage-format';
import { useSetupStore } from '@/store/setup-store';
-import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store';
+import { useCodexUsage } from '@/hooks/queries';
+import type { CodexRateLimitWindow } from '@/store/app-store';
-const ERROR_NO_API = 'Codex usage API not available';
const CODEX_USAGE_TITLE = 'Codex Usage';
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
@@ -21,14 +19,11 @@ const CODEX_LOGIN_COMMAND = 'codex login';
const CODEX_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
-const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
-const REFRESH_INTERVAL_MS = 60_000;
-const STALE_THRESHOLD_MS = 2 * 60_000;
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500';
@@ -39,11 +34,12 @@ const isRateLimitWindow = (
export function CodexUsageSection() {
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
- const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
- const [error, setError] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!codexAuthStatus?.authenticated;
+
+ // Use React Query for data fetching with automatic polling
+ const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
+
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
@@ -54,46 +50,7 @@ export function CodexUsageSection() {
? new Date(codexUsage.lastUpdated).toLocaleString()
: null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
- const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
-
- const fetchUsage = useCallback(async () => {
- setIsLoading(true);
- setError(null);
- try {
- const api = getElectronAPI();
- if (!api.codex) {
- setError(ERROR_NO_API);
- return;
- }
- const result = await api.codex.getUsage();
- if ('error' in result) {
- setError(result.message || result.error);
- return;
- }
- setCodexUsage(result);
- } catch (fetchError) {
- const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
- setError(message);
- } finally {
- setIsLoading(false);
- }
- }, [setCodexUsage]);
-
- useEffect(() => {
- if (canFetchUsage && isStale) {
- void fetchUsage();
- }
- }, [fetchUsage, canFetchUsage, isStale]);
-
- useEffect(() => {
- if (!canFetchUsage) return undefined;
-
- const intervalId = setInterval(() => {
- void fetchUsage();
- }, REFRESH_INTERVAL_MS);
-
- return () => clearInterval(intervalId);
- }, [fetchUsage, canFetchUsage]);
+ const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
@@ -162,13 +119,13 @@ export function CodexUsageSection() {
refetch()}
+ disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL}
>
-
+ {isFetching ? : }
{CODEX_USAGE_SUBTITLE}
@@ -182,10 +139,10 @@ export function CodexUsageSection() {
)}
- {error && (
+ {errorMessage && (
-
{error}
+
{errorMessage}
)}
{hasMetrics && (
@@ -210,7 +167,7 @@ export function CodexUsageSection() {
)}
- {!hasMetrics && !error && canFetchUsage && !isLoading && (
+ {!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
{CODEX_NO_USAGE_MESSAGE}
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/hooks/use-cursor-permissions.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts
index a911892e..a7327686 100644
--- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts
+++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts
@@ -1,103 +1,52 @@
-import { useState, useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { toast } from 'sonner';
+import { useState, useCallback, useEffect } from 'react';
+import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
+import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
-const logger = createLogger('CursorPermissions');
-import { getHttpApiClient } from '@/lib/http-api-client';
-import type { CursorPermissionProfile } from '@automaker/types';
-
-export interface PermissionsData {
- activeProfile: CursorPermissionProfile | null;
- effectivePermissions: { allow: string[]; deny: string[] } | null;
- hasProjectConfig: boolean;
- availableProfiles: Array<{
- id: string;
- name: string;
- description: string;
- permissions: { allow: string[]; deny: string[] };
- }>;
-}
+// Re-export for backward compatibility
+export type PermissionsData = CursorPermissionsData;
/**
* Custom hook for managing Cursor CLI permissions
* Handles loading permissions data, applying profiles, and copying configs
*/
export function useCursorPermissions(projectPath?: string) {
- const [permissions, setPermissions] = useState
(null);
- const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
- const [isSavingPermissions, setIsSavingPermissions] = useState(false);
const [copiedConfig, setCopiedConfig] = useState(false);
- // Load permissions data
- const loadPermissions = useCallback(async () => {
- setIsLoadingPermissions(true);
- try {
- const api = getHttpApiClient();
- const result = await api.setup.getCursorPermissions(projectPath);
-
- if (result.success) {
- setPermissions({
- activeProfile: result.activeProfile || null,
- effectivePermissions: result.effectivePermissions || null,
- hasProjectConfig: result.hasProjectConfig || false,
- availableProfiles: result.availableProfiles || [],
- });
- }
- } catch (error) {
- logger.error('Failed to load Cursor permissions:', error);
- } finally {
- setIsLoadingPermissions(false);
- }
- }, [projectPath]);
+ // React Query hooks
+ const permissionsQuery = useCursorPermissionsQuery(projectPath);
+ const applyProfileMutation = useApplyCursorProfile(projectPath);
+ const copyConfigMutation = useCopyCursorConfig();
// Apply a permission profile
const applyProfile = useCallback(
- async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
- setIsSavingPermissions(true);
- try {
- const api = getHttpApiClient();
- const result = await api.setup.applyCursorPermissionProfile(
- profileId,
- scope,
- scope === 'project' ? projectPath : undefined
- );
-
- if (result.success) {
- toast.success(result.message || `Applied ${profileId} profile`);
- await loadPermissions();
- } else {
- toast.error(result.error || 'Failed to apply profile');
- }
- } catch (error) {
- toast.error('Failed to apply profile');
- } finally {
- setIsSavingPermissions(false);
- }
+ (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
+ applyProfileMutation.mutate({ profileId, scope });
},
- [projectPath, loadPermissions]
+ [applyProfileMutation]
);
// Copy example config to clipboard
- const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
- try {
- const api = getHttpApiClient();
- const result = await api.setup.getCursorExampleConfig(profileId);
+ const copyConfig = useCallback(
+ (profileId: 'strict' | 'development') => {
+ copyConfigMutation.mutate(profileId, {
+ onSuccess: () => {
+ setCopiedConfig(true);
+ setTimeout(() => setCopiedConfig(false), 2000);
+ },
+ });
+ },
+ [copyConfigMutation]
+ );
- if (result.success && result.config) {
- await navigator.clipboard.writeText(result.config);
- setCopiedConfig(true);
- toast.success('Config copied to clipboard');
- setTimeout(() => setCopiedConfig(false), 2000);
- }
- } catch (error) {
- toast.error('Failed to copy config');
- }
- }, []);
+ // Load permissions (refetch)
+ const loadPermissions = useCallback(() => {
+ permissionsQuery.refetch();
+ }, [permissionsQuery]);
return {
- permissions,
- isLoadingPermissions,
- isSavingPermissions,
+ permissions: permissionsQuery.data ?? null,
+ isLoadingPermissions: permissionsQuery.isLoading,
+ isSavingPermissions: applyProfileMutation.isPending,
copiedConfig,
loadPermissions,
applyProfile,
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts
index a082e71b..6a39f7ca 100644
--- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts
+++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts
@@ -1,9 +1,5 @@
-import { useState, useEffect, useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
-import { toast } from 'sonner';
-
-const logger = createLogger('CursorStatus');
-import { getHttpApiClient } from '@/lib/http-api-client';
+import { useEffect, useMemo, useCallback } from 'react';
+import { useCursorCliStatus } from '@/hooks/queries';
import { useSetupStore } from '@/store/setup-store';
export interface CursorStatus {
@@ -15,52 +11,42 @@ export interface CursorStatus {
/**
* Custom hook for managing Cursor CLI status
- * Handles checking CLI installation, authentication, and refresh functionality
+ * Uses React Query for data fetching with automatic caching.
*/
export function useCursorStatus() {
const { setCursorCliStatus } = useSetupStore();
+ const { data: result, isLoading, refetch } = useCursorCliStatus();
- const [status, setStatus] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
-
- const loadData = useCallback(async () => {
- setIsLoading(true);
- try {
- const api = getHttpApiClient();
- const statusResult = await api.setup.getCursorStatus();
-
- if (statusResult.success) {
- const newStatus = {
- installed: statusResult.installed ?? false,
- version: statusResult.version ?? undefined,
- authenticated: statusResult.auth?.authenticated ?? false,
- method: statusResult.auth?.method,
- };
- setStatus(newStatus);
-
- // Also update the global setup store so other components can access the status
- setCursorCliStatus({
- installed: newStatus.installed,
- version: newStatus.version,
- auth: newStatus.authenticated
- ? {
- authenticated: true,
- method: newStatus.method || 'unknown',
- }
- : undefined,
- });
- }
- } catch (error) {
- logger.error('Failed to load Cursor settings:', error);
- toast.error('Failed to load Cursor settings');
- } finally {
- setIsLoading(false);
- }
- }, [setCursorCliStatus]);
+ // Transform the API result into the local CursorStatus shape
+ const status = useMemo((): CursorStatus | null => {
+ if (!result) return null;
+ return {
+ installed: result.installed ?? false,
+ version: result.version ?? undefined,
+ authenticated: result.auth?.authenticated ?? false,
+ method: result.auth?.method,
+ };
+ }, [result]);
+ // Keep the global setup store in sync with query data
useEffect(() => {
- loadData();
- }, [loadData]);
+ if (status) {
+ setCursorCliStatus({
+ installed: status.installed,
+ version: status.version,
+ auth: status.authenticated
+ ? {
+ authenticated: true,
+ method: status.method || 'unknown',
+ }
+ : undefined,
+ });
+ }
+ }, [status, setCursorCliStatus]);
+
+ const loadData = useCallback(() => {
+ refetch();
+ }, [refetch]);
return {
status,
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/bulk-replace-dialog.tsx b/apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx
new file mode 100644
index 00000000..aafd383d
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx
@@ -0,0 +1,343 @@
+import { useState, useMemo } from 'react';
+import { useAppStore } from '@/store/app-store';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type {
+ PhaseModelKey,
+ PhaseModelEntry,
+ ClaudeCompatibleProvider,
+ ClaudeModelAlias,
+} from '@automaker/types';
+import { DEFAULT_PHASE_MODELS } from '@automaker/types';
+
+interface BulkReplaceDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+// Phase display names for preview
+const PHASE_LABELS: Record = {
+ enhancementModel: 'Feature Enhancement',
+ fileDescriptionModel: 'File Descriptions',
+ imageDescriptionModel: 'Image Descriptions',
+ commitMessageModel: 'Commit Messages',
+ validationModel: 'GitHub Issue Validation',
+ specGenerationModel: 'App Specification',
+ featureGenerationModel: 'Feature Generation',
+ backlogPlanningModel: 'Backlog Planning',
+ projectAnalysisModel: 'Project Analysis',
+ suggestionsModel: 'AI Suggestions',
+ memoryExtractionModel: 'Memory Extraction',
+};
+
+const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
+
+// Claude model display names
+const CLAUDE_MODEL_DISPLAY: Record = {
+ haiku: 'Claude Haiku',
+ sonnet: 'Claude Sonnet',
+ opus: 'Claude Opus',
+};
+
+export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
+ const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
+ const [selectedProvider, setSelectedProvider] = useState('anthropic');
+
+ // Get enabled providers
+ const enabledProviders = useMemo(() => {
+ return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
+ }, [claudeCompatibleProviders]);
+
+ // Build provider options for the dropdown
+ const providerOptions = useMemo(() => {
+ const options: Array<{ id: string; name: string; isNative: boolean }> = [
+ { id: 'anthropic', name: 'Anthropic Direct', isNative: true },
+ ];
+
+ enabledProviders.forEach((provider) => {
+ options.push({
+ id: provider.id,
+ name: provider.name,
+ isNative: false,
+ });
+ });
+
+ return options;
+ }, [enabledProviders]);
+
+ // Get the selected provider config (if custom)
+ const selectedProviderConfig = useMemo(() => {
+ if (selectedProvider === 'anthropic') return null;
+ return enabledProviders.find((p) => p.id === selectedProvider);
+ }, [selectedProvider, enabledProviders]);
+
+ // Get the Claude model alias from a PhaseModelEntry
+ const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
+ // Check if model string directly matches a Claude alias
+ if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
+ if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
+ if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
+
+ // If it's a provider model, look up the mapping
+ if (entry.providerId) {
+ const provider = enabledProviders.find((p) => p.id === entry.providerId);
+ if (provider) {
+ const model = provider.models?.find((m) => m.id === entry.model);
+ if (model?.mapsToClaudeModel) {
+ return model.mapsToClaudeModel;
+ }
+ }
+ }
+
+ // Default to sonnet
+ return 'sonnet';
+ };
+
+ // Find the model from provider that maps to a specific Claude model
+ const findModelForClaudeAlias = (
+ provider: ClaudeCompatibleProvider | null,
+ claudeAlias: ClaudeModelAlias,
+ phase: PhaseModelKey
+ ): PhaseModelEntry => {
+ if (!provider) {
+ // Anthropic Direct - reset to default phase model (includes correct thinking levels)
+ return DEFAULT_PHASE_MODELS[phase];
+ }
+
+ // Find model that maps to this Claude alias
+ const models = provider.models || [];
+ const match = models.find((m) => m.mapsToClaudeModel === claudeAlias);
+
+ if (match) {
+ return { providerId: provider.id, model: match.id };
+ }
+
+ // Fallback: use first model if no match
+ if (models.length > 0) {
+ return { providerId: provider.id, model: models[0].id };
+ }
+
+ // Ultimate fallback to native Claude model
+ return { model: claudeAlias };
+ };
+
+ // Generate preview of changes
+ const preview = useMemo(() => {
+ return ALL_PHASES.map((phase) => {
+ const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
+ const claudeAlias = getClaudeModelAlias(currentEntry);
+ const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias, phase);
+
+ // Get display names
+ const getCurrentDisplay = (): string => {
+ if (currentEntry.providerId) {
+ const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
+ if (provider) {
+ const model = provider.models?.find((m) => m.id === currentEntry.model);
+ return model?.displayName || currentEntry.model;
+ }
+ }
+ return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
+ };
+
+ const getNewDisplay = (): string => {
+ if (newEntry.providerId && selectedProviderConfig) {
+ const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
+ return model?.displayName || newEntry.model;
+ }
+ return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
+ };
+
+ const isChanged =
+ currentEntry.model !== newEntry.model ||
+ currentEntry.providerId !== newEntry.providerId ||
+ currentEntry.thinkingLevel !== newEntry.thinkingLevel;
+
+ return {
+ phase,
+ label: PHASE_LABELS[phase],
+ claudeAlias,
+ currentDisplay: getCurrentDisplay(),
+ newDisplay: getNewDisplay(),
+ newEntry,
+ isChanged,
+ };
+ });
+ }, [phaseModels, selectedProviderConfig, enabledProviders]);
+
+ // Count how many will change
+ const changeCount = preview.filter((p) => p.isChanged).length;
+
+ // Apply the bulk replace
+ const handleApply = () => {
+ preview.forEach(({ phase, newEntry, isChanged }) => {
+ if (isChanged) {
+ setPhaseModel(phase, newEntry);
+ }
+ });
+ onOpenChange(false);
+ };
+
+ // Check if provider has all 3 Claude model mappings
+ const providerModelCoverage = useMemo(() => {
+ if (selectedProvider === 'anthropic') {
+ return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true };
+ }
+ if (!selectedProviderConfig) {
+ return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false };
+ }
+ const models = selectedProviderConfig.models || [];
+ const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku');
+ const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet');
+ const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus');
+ return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus };
+ }, [selectedProvider, selectedProviderConfig]);
+
+ const providerHasModels =
+ selectedProvider === 'anthropic' ||
+ (selectedProviderConfig && selectedProviderConfig.models?.length > 0);
+
+ return (
+
+
+
+ Bulk Replace Models
+
+ Switch all phase models to equivalents from a specific provider. Models are matched by
+ their Claude model mapping (Haiku, Sonnet, Opus).
+
+
+
+
+ {/* Provider selector */}
+
+
Target Provider
+
+
+
+
+
+ {providerOptions.map((option) => (
+
+
+ {option.isNative ? (
+
+ ) : (
+
+ )}
+ {option.name}
+
+
+ ))}
+
+
+
+
+ {/* Warning if provider has no models */}
+ {!providerHasModels && (
+
+
+
+
This provider has no models configured.
+
+
+ )}
+
+ {/* Warning if provider doesn't have all 3 mappings */}
+ {providerHasModels && !providerModelCoverage.complete && (
+
+
+
+
+ This provider is missing mappings for:{' '}
+ {[
+ !providerModelCoverage.hasHaiku && 'Haiku',
+ !providerModelCoverage.hasSonnet && 'Sonnet',
+ !providerModelCoverage.hasOpus && 'Opus',
+ ]
+ .filter(Boolean)
+ .join(', ')}
+
+
+
+ )}
+
+ {/* Preview of changes */}
+ {providerHasModels && (
+
+
+ Preview Changes
+
+ {changeCount} of {ALL_PHASES.length} will change
+
+
+
+
+
+
+ Phase
+ Current
+
+ New
+
+
+
+ {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
+
+ {label}
+ {currentDisplay}
+
+ {isChanged ? (
+
+ ) : (
+
+ )}
+
+
+
+ {newDisplay}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Apply Changes ({changeCount})
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx
index 37f3e72d..e12000fb 100644
--- a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx
+++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx
@@ -1,8 +1,10 @@
-import { Workflow, RotateCcw } from 'lucide-react';
+import { useState } from 'react';
+import { Workflow, RotateCcw, Replace } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { PhaseModelSelector } from './phase-model-selector';
+import { BulkReplaceDialog } from './bulk-replace-dialog';
import type { PhaseModelKey } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
@@ -112,7 +114,12 @@ function PhaseGroup({
}
export function ModelDefaultsSection() {
- const { resetPhaseModels } = useAppStore();
+ const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
+ const [showBulkReplace, setShowBulkReplace] = useState(false);
+
+ // Check if there are any enabled ClaudeCompatibleProviders
+ const hasEnabledProviders =
+ claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
return (
-
-
- Reset to Defaults
-
+
+ {hasEnabledProviders && (
+ setShowBulkReplace(true)}
+ className="gap-2"
+ >
+
+ Bulk Replace
+
+ )}
+
+
+ Reset to Defaults
+
+
+ {/* Bulk Replace Dialog */}
+
+
{/* Content */}
{/* Quick Tasks */}
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..364d435f 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
@@ -9,6 +9,9 @@ import type {
OpencodeModelId,
GroupedModel,
PhaseModelEntry,
+ ClaudeCompatibleProvider,
+ ProviderModel,
+ ClaudeModelAlias,
} from '@automaker/types';
import {
stripProviderPrefix,
@@ -33,6 +36,9 @@ import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
+ OpenRouterIcon,
+ GlmIcon,
+ MiniMaxIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -154,10 +160,12 @@ export function PhaseModelSelector({
const [expandedGroup, setExpandedGroup] = useState
(null);
const [expandedClaudeModel, setExpandedClaudeModel] = useState(null);
const [expandedCodexModel, setExpandedCodexModel] = useState(null);
+ const [expandedProviderModel, setExpandedProviderModel] = useState(null); // Format: providerId:modelId
const commandListRef = useRef(null);
const expandedTriggerRef = useRef(null);
const expandedClaudeTriggerRef = useRef(null);
const expandedCodexTriggerRef = useRef(null);
+ const expandedProviderTriggerRef = useRef(null);
const {
enabledCursorModels,
favoriteModels,
@@ -170,16 +178,23 @@ export function PhaseModelSelector({
opencodeModelsLoading,
fetchOpencodeModels,
disabledProviders,
+ claudeCompatibleProviders,
} = useAppStore();
// Detect mobile devices to use inline expansion instead of nested popovers
const isMobile = useIsMobile();
- // Extract model and thinking/reasoning levels from value
+ // Extract model, provider, and thinking/reasoning levels from value
const selectedModel = value.model;
+ const selectedProviderId = value.providerId;
const selectedThinkingLevel = value.thinkingLevel || 'none';
const selectedReasoningEffort = value.reasoningEffort || 'none';
+ // Get enabled providers and their models
+ const enabledProviders = useMemo(() => {
+ return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
+ }, [claudeCompatibleProviders]);
+
// Fetch Codex models on mount
useEffect(() => {
if (codexModels.length === 0 && !codexModelsLoading) {
@@ -267,6 +282,29 @@ export function PhaseModelSelector({
return () => observer.disconnect();
}, [expandedCodexModel]);
+ // Close expanded provider model popover when trigger scrolls out of view
+ useEffect(() => {
+ const triggerElement = expandedProviderTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedProviderModel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedProviderModel(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1,
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedProviderModel]);
+
// Transform dynamic Codex models from store to component format
const transformedCodexModels = useMemo(() => {
return codexModels.map((model) => ({
@@ -279,8 +317,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 +338,7 @@ export function PhaseModelSelector({
};
}
+ // With canonical IDs, direct comparison works
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
@@ -336,13 +375,93 @@ export function PhaseModelSelector({
};
}
+ // Check ClaudeCompatibleProvider models (when providerId is set)
+ if (selectedProviderId) {
+ const provider = enabledProviders.find((p) => p.id === selectedProviderId);
+ if (provider) {
+ const providerModel = provider.models?.find((m) => m.id === selectedModel);
+ if (providerModel) {
+ // Count providers of same type to determine if we need provider name suffix
+ const sameTypeCount = enabledProviders.filter(
+ (p) => p.providerType === provider.providerType
+ ).length;
+ const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
+ // Add thinking level to label if not 'none'
+ const thinkingLabel =
+ selectedThinkingLevel !== 'none'
+ ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
+ : '';
+ // Get icon based on provider type
+ const getIconForProviderType = () => {
+ switch (provider.providerType) {
+ case 'glm':
+ return GlmIcon;
+ case 'minimax':
+ return MiniMaxIcon;
+ case 'openrouter':
+ return OpenRouterIcon;
+ default:
+ return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
+ }
+ };
+ return {
+ id: selectedModel,
+ label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
+ description: provider.name,
+ provider: 'claude-compatible' as const,
+ icon: getIconForProviderType(),
+ };
+ }
+ }
+ }
+
+ // Fallback: Check ClaudeCompatibleProvider models by model ID only (when providerId is not set)
+ // This handles cases where features store model ID but not providerId
+ for (const provider of enabledProviders) {
+ const providerModel = provider.models?.find((m) => m.id === selectedModel);
+ if (providerModel) {
+ // Count providers of same type to determine if we need provider name suffix
+ const sameTypeCount = enabledProviders.filter(
+ (p) => p.providerType === provider.providerType
+ ).length;
+ const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
+ // Add thinking level to label if not 'none'
+ const thinkingLabel =
+ selectedThinkingLevel !== 'none'
+ ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
+ : '';
+ // Get icon based on provider type
+ const getIconForProviderType = () => {
+ switch (provider.providerType) {
+ case 'glm':
+ return GlmIcon;
+ case 'minimax':
+ return MiniMaxIcon;
+ case 'openrouter':
+ return OpenRouterIcon;
+ default:
+ return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
+ }
+ };
+ return {
+ id: selectedModel,
+ label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
+ description: provider.name,
+ provider: 'claude-compatible' as const,
+ icon: getIconForProviderType(),
+ };
+ }
+ }
+
return null;
}, [
selectedModel,
+ selectedProviderId,
selectedThinkingLevel,
availableCursorModels,
transformedCodexModels,
dynamicOpencodeModels,
+ enabledProviders,
]);
// Compute grouped vs standalone Cursor models
@@ -352,7 +471,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)) {
@@ -906,10 +1025,249 @@ export function PhaseModelSelector({
);
};
+ // Render ClaudeCompatibleProvider model item with thinking level support
+ const renderProviderModelItem = (
+ provider: ClaudeCompatibleProvider,
+ model: ProviderModel,
+ showProviderSuffix: boolean,
+ allMappedModels: ClaudeModelAlias[] = []
+ ) => {
+ const isSelected = selectedModel === model.id && selectedProviderId === provider.id;
+ const expandKey = `${provider.id}:${model.id}`;
+ const isExpanded = expandedProviderModel === expandKey;
+ const currentThinking = isSelected ? selectedThinkingLevel : 'none';
+ const displayName = showProviderSuffix
+ ? `${model.displayName} (${provider.name})`
+ : model.displayName;
+
+ // Build description showing all mapped Claude models
+ const modelLabelMap: Record = {
+ haiku: 'Haiku',
+ sonnet: 'Sonnet',
+ opus: 'Opus',
+ };
+ // Sort in order: haiku, sonnet, opus for consistent display
+ const sortOrder: ClaudeModelAlias[] = ['haiku', 'sonnet', 'opus'];
+ const sortedMappedModels = [...allMappedModels].sort(
+ (a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)
+ );
+ const mappedModelLabel =
+ sortedMappedModels.length > 0
+ ? sortedMappedModels.map((m) => modelLabelMap[m]).join(', ')
+ : 'Claude';
+
+ // Get icon based on provider type, falling back to model-based detection
+ const getProviderTypeIcon = () => {
+ switch (provider.providerType) {
+ case 'glm':
+ return GlmIcon;
+ case 'minimax':
+ return MiniMaxIcon;
+ case 'openrouter':
+ return OpenRouterIcon;
+ default:
+ // For generic/unknown providers, use OpenRouter as a generic "cloud API" icon
+ // unless the model ID has a recognizable pattern
+ return getProviderIconForModel(model.id) || OpenRouterIcon;
+ }
+ };
+ const ProviderIcon = getProviderTypeIcon();
+
+ // On mobile, render inline expansion instead of nested popover
+ if (isMobile) {
+ return (
+
+
setExpandedProviderModel(isExpanded ? null : expandKey)}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {displayName}
+
+
+ {isSelected && currentThinking !== 'none'
+ ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
+ : `Maps to ${mappedModelLabel}`}
+
+
+
+
+
+ {isSelected && !isExpanded && }
+
+
+
+
+ {/* Inline thinking level options on mobile */}
+ {isExpanded && (
+
+
+ Thinking Level
+
+ {THINKING_LEVELS.map((level) => (
+
{
+ onChange({
+ providerId: provider.id,
+ model: model.id,
+ thinkingLevel: level,
+ });
+ setExpandedProviderModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {THINKING_LEVEL_LABELS[level]}
+
+ {level === 'none' && 'No extended thinking'}
+ {level === 'low' && 'Light reasoning (1k tokens)'}
+ {level === 'medium' && 'Moderate reasoning (10k tokens)'}
+ {level === 'high' && 'Deep reasoning (16k tokens)'}
+ {level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
+
+
+ {isSelected && currentThinking === level && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Desktop: Use nested popover
+ return (
+ setExpandedProviderModel(isExpanded ? null : expandKey)}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedProviderModel(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {displayName}
+
+
+ {isSelected && currentThinking !== 'none'
+ ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
+ : `Maps to ${mappedModelLabel}`}
+
+
+
+
+
+ {isSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ Thinking Level
+
+ {THINKING_LEVELS.map((level) => (
+
{
+ onChange({
+ providerId: provider.id,
+ model: model.id,
+ thinkingLevel: level,
+ });
+ setExpandedProviderModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {THINKING_LEVEL_LABELS[level]}
+
+ {level === 'none' && 'No extended thinking'}
+ {level === 'low' && 'Light reasoning (1k tokens)'}
+ {level === 'medium' && 'Moderate reasoning (10k tokens)'}
+ {level === 'high' && 'Deep reasoning (16k tokens)'}
+ {level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
+
+
+ {isSelected && currentThinking === level && (
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ };
+
// 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 +1275,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 +1816,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
@@ -1498,6 +1856,50 @@ export function PhaseModelSelector({
)}
+ {/* ClaudeCompatibleProvider Models - each provider as separate group */}
+ {enabledProviders.map((provider) => {
+ if (!provider.models || provider.models.length === 0) return null;
+
+ // Check if we need provider suffix (multiple providers of same type)
+ const sameTypeCount = enabledProviders.filter(
+ (p) => p.providerType === provider.providerType
+ ).length;
+ const showSuffix = sameTypeCount > 1;
+
+ // Group models by ID and collect all mapped Claude models for each
+ const modelsByIdMap = new Map<
+ string,
+ { model: ProviderModel; mappedModels: ClaudeModelAlias[] }
+ >();
+ for (const model of provider.models) {
+ const existing = modelsByIdMap.get(model.id);
+ if (existing) {
+ // Add this mapped model if not already present
+ if (
+ model.mapsToClaudeModel &&
+ !existing.mappedModels.includes(model.mapsToClaudeModel)
+ ) {
+ existing.mappedModels.push(model.mapsToClaudeModel);
+ }
+ } else {
+ // First occurrence of this model ID
+ modelsByIdMap.set(model.id, {
+ model,
+ mappedModels: model.mapsToClaudeModel ? [model.mapsToClaudeModel] : [],
+ });
+ }
+ }
+ const uniqueModelsWithMappings = Array.from(modelsByIdMap.values());
+
+ return (
+
+ {uniqueModelsWithMappings.map(({ model, mappedModels }) =>
+ renderProviderModelItem(provider, model, showSuffix, mappedModels)
+ )}
+
+ );
+ })}
+
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
{/* Grouped models with secondary popover */}
diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx
index 38b34c4c..57b432d0 100644
--- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx
+++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab.tsx
@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
import { SkillsSection } from './claude-settings-tab/skills-section';
import { SubagentsSection } from './claude-settings-tab/subagents-section';
+import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section';
import { ProviderToggle } from './provider-toggle';
import { Info } from 'lucide-react';
@@ -45,6 +46,10 @@ export function ClaudeSettingsTab() {
isChecking={isCheckingClaudeCli}
onRefresh={handleRefreshClaudeCli}
/>
+
+ {/* Claude-compatible providers */}
+
+
= {
+ anthropic: 'Anthropic',
+ glm: 'GLM',
+ minimax: 'MiniMax',
+ openrouter: 'OpenRouter',
+ custom: 'Custom',
+};
+
+// Provider type badge colors
+const PROVIDER_TYPE_COLORS: Record = {
+ anthropic: 'bg-brand-500/20 text-brand-500',
+ glm: 'bg-emerald-500/20 text-emerald-500',
+ minimax: 'bg-purple-500/20 text-purple-500',
+ openrouter: 'bg-amber-500/20 text-amber-500',
+ custom: 'bg-zinc-500/20 text-zinc-400',
+};
+
+// Claude model display names
+const CLAUDE_MODEL_LABELS: Record = {
+ haiku: 'Claude Haiku',
+ sonnet: 'Claude Sonnet',
+ opus: 'Claude Opus',
+};
+
+interface ModelFormEntry {
+ id: string;
+ displayName: string;
+ mapsToClaudeModel: ClaudeModelAlias;
+}
+
+interface ProviderFormData {
+ name: string;
+ providerType: ClaudeCompatibleProviderType;
+ baseUrl: string;
+ apiKeySource: ApiKeySource;
+ apiKey: string;
+ useAuthToken: boolean;
+ timeoutMs: string; // String for input, convert to number
+ models: ModelFormEntry[];
+ disableNonessentialTraffic: boolean;
+}
+
+const emptyFormData: ProviderFormData = {
+ name: '',
+ providerType: 'custom',
+ baseUrl: '',
+ apiKeySource: 'inline',
+ apiKey: '',
+ useAuthToken: false,
+ timeoutMs: '',
+ models: [],
+ disableNonessentialTraffic: false,
+};
+
+// Provider types that have fixed settings (no need to show toggles)
+const FIXED_SETTINGS_PROVIDERS: ClaudeCompatibleProviderType[] = ['glm', 'minimax'];
+
+// Check if provider type has fixed settings
+function hasFixedSettings(providerType: ClaudeCompatibleProviderType): boolean {
+ return FIXED_SETTINGS_PROVIDERS.includes(providerType);
+}
+
+export function ApiProfilesSection() {
+ const {
+ claudeCompatibleProviders,
+ addClaudeCompatibleProvider,
+ updateClaudeCompatibleProvider,
+ deleteClaudeCompatibleProvider,
+ toggleClaudeCompatibleProviderEnabled,
+ } = useAppStore();
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [editingProviderId, setEditingProviderId] = useState(null);
+ const [formData, setFormData] = useState(emptyFormData);
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null);
+ const [currentTemplate, setCurrentTemplate] = useState<
+ (typeof CLAUDE_PROVIDER_TEMPLATES)[0] | null
+ >(null);
+ const [showModelMappings, setShowModelMappings] = useState(false);
+
+ const handleOpenAddDialog = (templateName?: string) => {
+ const template = templateName
+ ? CLAUDE_PROVIDER_TEMPLATES.find((t) => t.name === templateName)
+ : undefined;
+
+ if (template) {
+ setFormData({
+ name: template.name,
+ providerType: template.providerType,
+ baseUrl: template.baseUrl,
+ apiKeySource: template.defaultApiKeySource ?? 'inline',
+ apiKey: '',
+ useAuthToken: template.useAuthToken,
+ timeoutMs: template.timeoutMs?.toString() ?? '',
+ models: (template.defaultModels || []).map((m) => ({
+ id: m.id,
+ displayName: m.displayName,
+ mapsToClaudeModel: m.mapsToClaudeModel || 'sonnet',
+ })),
+ disableNonessentialTraffic: template.disableNonessentialTraffic ?? false,
+ });
+ setCurrentTemplate(template);
+ } else {
+ setFormData(emptyFormData);
+ setCurrentTemplate(null);
+ }
+
+ setEditingProviderId(null);
+ setShowApiKey(false);
+ // For fixed providers, hide model mappings by default (they have sensible defaults)
+ setShowModelMappings(template ? !hasFixedSettings(template.providerType) : true);
+ setIsDialogOpen(true);
+ };
+
+ const handleOpenEditDialog = (provider: ClaudeCompatibleProvider) => {
+ // Find matching template by provider type
+ const template = CLAUDE_PROVIDER_TEMPLATES.find(
+ (t) => t.providerType === provider.providerType
+ );
+
+ setFormData({
+ name: provider.name,
+ providerType: provider.providerType,
+ baseUrl: provider.baseUrl,
+ apiKeySource: provider.apiKeySource ?? 'inline',
+ apiKey: provider.apiKey ?? '',
+ useAuthToken: provider.useAuthToken ?? false,
+ timeoutMs: provider.timeoutMs?.toString() ?? '',
+ models: (provider.models || []).map((m) => ({
+ id: m.id,
+ displayName: m.displayName,
+ mapsToClaudeModel: m.mapsToClaudeModel || 'sonnet',
+ })),
+ disableNonessentialTraffic: provider.disableNonessentialTraffic ?? false,
+ });
+ setEditingProviderId(provider.id);
+ setCurrentTemplate(template ?? null);
+ setShowApiKey(false);
+ // For fixed providers, hide model mappings by default when editing
+ setShowModelMappings(!hasFixedSettings(provider.providerType));
+ setIsDialogOpen(true);
+ };
+
+ const handleSave = () => {
+ // For GLM/MiniMax, enforce fixed settings
+ const isFixedProvider = hasFixedSettings(formData.providerType);
+
+ // Convert form models to ProviderModel format
+ const models: ProviderModel[] = formData.models
+ .filter((m) => m.id.trim()) // Only include models with IDs
+ .map((m) => ({
+ id: m.id.trim(),
+ displayName: m.displayName.trim() || m.id.trim(),
+ mapsToClaudeModel: m.mapsToClaudeModel,
+ }));
+
+ // Preserve enabled state when editing, default to true for new providers
+ const existingProvider = editingProviderId
+ ? claudeCompatibleProviders.find((p) => p.id === editingProviderId)
+ : undefined;
+
+ const providerData: ClaudeCompatibleProvider = {
+ id: editingProviderId ?? generateProviderId(),
+ name: formData.name.trim(),
+ providerType: formData.providerType,
+ enabled: existingProvider?.enabled ?? true,
+ baseUrl: formData.baseUrl.trim(),
+ // For fixed providers, always use inline
+ apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource,
+ // Only include apiKey when source is 'inline'
+ apiKey: isFixedProvider || formData.apiKeySource === 'inline' ? formData.apiKey : undefined,
+ // For fixed providers, always use auth token
+ useAuthToken: isFixedProvider ? true : formData.useAuthToken,
+ timeoutMs: (() => {
+ const parsed = Number(formData.timeoutMs);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ })(),
+ models,
+ // For fixed providers, always disable non-essential
+ disableNonessentialTraffic: isFixedProvider
+ ? true
+ : formData.disableNonessentialTraffic || undefined,
+ };
+
+ if (editingProviderId) {
+ updateClaudeCompatibleProvider(editingProviderId, providerData);
+ } else {
+ addClaudeCompatibleProvider(providerData);
+ }
+
+ setIsDialogOpen(false);
+ setFormData(emptyFormData);
+ setEditingProviderId(null);
+ };
+
+ const handleDelete = (id: string) => {
+ deleteClaudeCompatibleProvider(id);
+ setDeleteConfirmId(null);
+ };
+
+ const handleAddModel = () => {
+ setFormData({
+ ...formData,
+ models: [...formData.models, { id: '', displayName: '', mapsToClaudeModel: 'sonnet' }],
+ });
+ };
+
+ const handleUpdateModel = (index: number, updates: Partial) => {
+ const newModels = [...formData.models];
+ newModels[index] = { ...newModels[index], ...updates };
+ setFormData({ ...formData, models: newModels });
+ };
+
+ const handleRemoveModel = (index: number) => {
+ setFormData({
+ ...formData,
+ models: formData.models.filter((_, i) => i !== index),
+ });
+ };
+
+ // Check for duplicate provider name (case-insensitive, excluding current provider when editing)
+ const isDuplicateName = claudeCompatibleProviders.some(
+ (p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProviderId
+ );
+
+ // For fixed providers, API key is always required (inline only)
+ // For others, only required when source is 'inline'
+ const isFixedProvider = hasFixedSettings(formData.providerType);
+ const isFormValid =
+ formData.name.trim().length > 0 &&
+ formData.baseUrl.trim().length > 0 &&
+ (isFixedProvider
+ ? formData.apiKey.length > 0
+ : formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) &&
+ !isDuplicateName;
+
+ // Check model coverage
+ const modelCoverage = {
+ hasHaiku: formData.models.some((m) => m.mapsToClaudeModel === 'haiku'),
+ hasSonnet: formData.models.some((m) => m.mapsToClaudeModel === 'sonnet'),
+ hasOpus: formData.models.some((m) => m.mapsToClaudeModel === 'opus'),
+ };
+ const hasAllMappings = modelCoverage.hasHaiku && modelCoverage.hasSonnet && modelCoverage.hasOpus;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Model Providers
+
+ Configure providers whose models appear in all model selectors
+
+
+
+
+
+
+
+ Add Provider
+
+
+
+ handleOpenAddDialog()}>
+
+ Custom Provider
+
+
+ {CLAUDE_PROVIDER_TEMPLATES.filter((t) => t.providerType !== 'anthropic').map(
+ (template) => (
+ handleOpenAddDialog(template.name)}
+ >
+
+ {template.name}
+
+ )
+ )}
+
+
+
+
+ {/* Content */}
+
+ {/* Info Banner */}
+
+ Models from enabled providers appear in all model dropdowns throughout the app. You can
+ select different models from different providers for each phase.
+
+
+ {/* Provider List */}
+ {claudeCompatibleProviders.length === 0 ? (
+
+
+
No model providers configured
+
+ Add a provider to use alternative Claude-compatible models
+
+
+ ) : (
+
+ {claudeCompatibleProviders.map((provider) => (
+
handleOpenEditDialog(provider)}
+ onDelete={() => setDeleteConfirmId(provider.id)}
+ onToggleEnabled={() => toggleClaudeCompatibleProviderEnabled(provider.id)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Add/Edit Dialog */}
+
+
+
+
+ {editingProviderId ? 'Edit Model Provider' : 'Add Model Provider'}
+
+
+ {isFixedProvider
+ ? `Configure ${PROVIDER_TYPE_LABELS[formData.providerType]} endpoint with model mappings to Claude.`
+ : 'Configure a Claude-compatible API endpoint. Models from this provider will appear in all model selectors.'}
+
+
+
+
+ {/* Name */}
+
+
Provider Name
+
setFormData({ ...formData, name: e.target.value })}
+ placeholder="e.g., GLM (Work)"
+ className={isDuplicateName ? 'border-destructive' : ''}
+ />
+ {isDuplicateName && (
+
A provider with this name already exists
+ )}
+
+
+ {/* Provider Type - only for custom providers */}
+ {!isFixedProvider && (
+
+ Provider Type
+
+ setFormData({ ...formData, providerType: value })
+ }
+ >
+
+
+
+
+ GLM (z.AI)
+ MiniMax
+ OpenRouter
+ Anthropic
+ Custom
+
+
+
+ )}
+
+ {/* API Key - always shown first for fixed providers */}
+
+
API Key
+
+ setFormData({ ...formData, apiKey: e.target.value })}
+ placeholder="Enter API key"
+ className="pr-10"
+ />
+ setShowApiKey(!showApiKey)}
+ >
+ {showApiKey ? : }
+
+
+ {currentTemplate?.apiKeyUrl && (
+
+ Get API Key from {currentTemplate.name}
+
+ )}
+
+
+ {/* Base URL - hidden for fixed providers since it's pre-configured */}
+ {!isFixedProvider && (
+
+ API Base URL
+ setFormData({ ...formData, baseUrl: e.target.value })}
+ placeholder="https://api.example.com/v1"
+ />
+
+ )}
+
+ {/* Advanced options for non-fixed providers only */}
+ {!isFixedProvider && (
+ <>
+ {/* API Key Source */}
+
+ API Key Source
+
+ setFormData({ ...formData, apiKeySource: value })
+ }
+ >
+
+
+
+
+ Enter key for this provider only
+
+ Use saved API key (from Settings → API Keys)
+
+
+ Use environment variable (ANTHROPIC_API_KEY)
+
+
+
+
+
+ {/* Use Auth Token */}
+
+
+
+ Use Auth Token
+
+
+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY
+
+
+
+ setFormData({ ...formData, useAuthToken: checked })
+ }
+ />
+
+
+ {/* Disable Non-essential Traffic */}
+
+
+
+ Disable Non-essential Traffic
+
+
+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
+
+
+
+ setFormData({ ...formData, disableNonessentialTraffic: checked })
+ }
+ />
+
+ >
+ )}
+
+ {/* Timeout */}
+
+ Timeout (ms)
+ setFormData({ ...formData, timeoutMs: e.target.value })}
+ placeholder="Optional, e.g., 3000000"
+ />
+
+
+ {/* Models */}
+
+ {/* For fixed providers, show collapsible section */}
+ {isFixedProvider ? (
+ <>
+
+
+
Model Mappings
+
+ {formData.models.length} mappings configured (Haiku, Sonnet, Opus)
+
+
+
setShowModelMappings(!showModelMappings)}
+ className="gap-2"
+ >
+
+ {showModelMappings ? 'Hide' : 'Customize'}
+
+
+
+
+ {/* Expanded model mappings for fixed providers */}
+ {showModelMappings && (
+
+ {formData.models.map((model, index) => (
+
+
+
+
+
+ Maps to Claude Model
+
+
+ handleUpdateModel(index, { mapsToClaudeModel: value })
+ }
+ >
+
+
+
+
+ Haiku (fast, efficient)
+ Sonnet (balanced)
+ Opus (powerful)
+
+
+
+
+
handleRemoveModel(index)}
+ >
+
+
+
+ ))}
+
+
+ Add Model
+
+
+ )}
+ >
+ ) : (
+ <>
+ {/* Non-fixed providers: always show full editing UI */}
+
+
+
Model Mappings
+
+ Map provider models to Claude equivalents (Haiku, Sonnet, Opus)
+
+
+
+
+ Add Model
+
+
+
+ {/* Coverage warning - only for non-fixed providers */}
+ {formData.models.length > 0 && !hasAllMappings && (
+
+ Missing mappings:{' '}
+ {[
+ !modelCoverage.hasHaiku && 'Haiku',
+ !modelCoverage.hasSonnet && 'Sonnet',
+ !modelCoverage.hasOpus && 'Opus',
+ ]
+ .filter(Boolean)
+ .join(', ')}
+
+ )}
+
+ {formData.models.length === 0 ? (
+
+ No models configured. Add models to use with this provider.
+
+ ) : (
+
+ {formData.models.map((model, index) => (
+
+
+
+
+
+ Maps to Claude Model
+
+
+ handleUpdateModel(index, { mapsToClaudeModel: value })
+ }
+ >
+
+
+
+
+ Haiku (fast, efficient)
+ Sonnet (balanced)
+ Opus (powerful)
+
+
+
+
+
handleRemoveModel(index)}
+ >
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+
+ setIsDialogOpen(false)}>
+ Cancel
+
+
+ {editingProviderId ? 'Save Changes' : 'Add Provider'}
+
+
+
+
+
+ {/* Delete Confirmation Dialog */}
+
!open && setDeleteConfirmId(null)}>
+
+
+ Delete Provider?
+
+ This will permanently delete the provider and its models. Any phase model
+ configurations using these models will need to be updated.
+
+
+
+ setDeleteConfirmId(null)}>
+ Cancel
+
+ deleteConfirmId && handleDelete(deleteConfirmId)}
+ >
+ Delete
+
+
+
+
+
+ );
+}
+
+interface ProviderCardProps {
+ provider: ClaudeCompatibleProvider;
+ onEdit: () => void;
+ onDelete: () => void;
+ onToggleEnabled: () => void;
+}
+
+function ProviderCard({ provider, onEdit, onDelete, onToggleEnabled }: ProviderCardProps) {
+ const isEnabled = provider.enabled !== false;
+
+ return (
+
+
+
+
+
{provider.name}
+
+ {PROVIDER_TYPE_LABELS[provider.providerType]}
+
+ {!isEnabled && (
+
+ Disabled
+
+ )}
+
+
{provider.baseUrl}
+
+ Key: {maskApiKey(provider.apiKey)}
+ {provider.models?.length || 0} model(s)
+
+ {/* Show models with their Claude mapping */}
+ {provider.models && provider.models.length > 0 && (
+
+ {provider.models.map((model) => (
+
+ {model.displayName || model.id}
+ {model.mapsToClaudeModel && (
+
+ → {CLAUDE_MODEL_LABELS[model.mapsToClaudeModel]}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+
+ Delete
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts
index 233e0fdd..3542b951 100644
--- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts
+++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts
@@ -5,59 +5,53 @@
* configuring which sources to load Skills from (user/project).
*/
-import { useState } from 'react';
+import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
-import { getElectronAPI } from '@/lib/electron';
+import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSkillsSettings() {
const enabled = useAppStore((state) => state.enableSkills);
const sources = useAppStore((state) => state.skillsSources);
- const [isLoading, setIsLoading] = useState(false);
- const updateEnabled = async (newEnabled: boolean) => {
- setIsLoading(true);
- try {
- const api = getElectronAPI();
- if (!api.settings) {
- throw new Error('Settings API not available');
- }
- await api.settings.updateGlobal({ enableSkills: newEnabled });
- // Update local store after successful server update
- useAppStore.setState({ enableSkills: newEnabled });
- toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
- } catch (error) {
- toast.error('Failed to update skills settings');
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- };
+ // React Query mutation (disable default toast)
+ const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
- const updateSources = async (newSources: Array<'user' | 'project'>) => {
- setIsLoading(true);
- try {
- const api = getElectronAPI();
- if (!api.settings) {
- throw new Error('Settings API not available');
- }
- await api.settings.updateGlobal({ skillsSources: newSources });
- // Update local store after successful server update
- useAppStore.setState({ skillsSources: newSources });
- toast.success('Skills sources updated');
- } catch (error) {
- toast.error('Failed to update skills sources');
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- };
+ const updateEnabled = useCallback(
+ (newEnabled: boolean) => {
+ updateSettingsMutation.mutate(
+ { enableSkills: newEnabled },
+ {
+ onSuccess: () => {
+ useAppStore.setState({ enableSkills: newEnabled });
+ toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
+ },
+ }
+ );
+ },
+ [updateSettingsMutation]
+ );
+
+ const updateSources = useCallback(
+ (newSources: Array<'user' | 'project'>) => {
+ updateSettingsMutation.mutate(
+ { skillsSources: newSources },
+ {
+ onSuccess: () => {
+ useAppStore.setState({ skillsSources: newSources });
+ toast.success('Skills sources updated');
+ },
+ }
+ );
+ },
+ [updateSettingsMutation]
+ );
return {
enabled,
sources,
updateEnabled,
updateSources,
- isLoading,
+ isLoading: updateSettingsMutation.isPending,
};
}
diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts
index ccf7664a..dfc55cd0 100644
--- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts
+++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts
@@ -5,59 +5,53 @@
* configuring which sources to load Subagents from (user/project).
*/
-import { useState } from 'react';
+import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
-import { getElectronAPI } from '@/lib/electron';
+import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSubagentsSettings() {
const enabled = useAppStore((state) => state.enableSubagents);
const sources = useAppStore((state) => state.subagentsSources);
- const [isLoading, setIsLoading] = useState(false);
- const updateEnabled = async (newEnabled: boolean) => {
- setIsLoading(true);
- try {
- const api = getElectronAPI();
- if (!api.settings) {
- throw new Error('Settings API not available');
- }
- await api.settings.updateGlobal({ enableSubagents: newEnabled });
- // Update local store after successful server update
- useAppStore.setState({ enableSubagents: newEnabled });
- toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
- } catch (error) {
- toast.error('Failed to update subagents settings');
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- };
+ // React Query mutation (disable default toast)
+ const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
- const updateSources = async (newSources: Array<'user' | 'project'>) => {
- setIsLoading(true);
- try {
- const api = getElectronAPI();
- if (!api.settings) {
- throw new Error('Settings API not available');
- }
- await api.settings.updateGlobal({ subagentsSources: newSources });
- // Update local store after successful server update
- useAppStore.setState({ subagentsSources: newSources });
- toast.success('Subagents sources updated');
- } catch (error) {
- toast.error('Failed to update subagents sources');
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- };
+ const updateEnabled = useCallback(
+ (newEnabled: boolean) => {
+ updateSettingsMutation.mutate(
+ { enableSubagents: newEnabled },
+ {
+ onSuccess: () => {
+ useAppStore.setState({ enableSubagents: newEnabled });
+ toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
+ },
+ }
+ );
+ },
+ [updateSettingsMutation]
+ );
+
+ const updateSources = useCallback(
+ (newSources: Array<'user' | 'project'>) => {
+ updateSettingsMutation.mutate(
+ { subagentsSources: newSources },
+ {
+ onSuccess: () => {
+ useAppStore.setState({ subagentsSources: newSources });
+ toast.success('Subagents sources updated');
+ },
+ }
+ );
+ },
+ [updateSettingsMutation]
+ );
return {
enabled,
sources,
updateEnabled,
updateSources,
- isLoading,
+ isLoading: updateSettingsMutation.isPending,
};
}
diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts
index 50f82393..475f8378 100644
--- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts
+++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts
@@ -9,10 +9,12 @@
* Agent definitions in settings JSON are used server-side only.
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useMemo, useCallback } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store';
import type { AgentDefinition } from '@automaker/types';
-import { getElectronAPI } from '@/lib/electron';
+import { useDiscoveredAgents } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
export type SubagentScope = 'global' | 'project';
export type SubagentType = 'filesystem';
@@ -35,51 +37,40 @@ interface FilesystemAgent {
}
export function useSubagents() {
+ const queryClient = useQueryClient();
const currentProject = useAppStore((state) => state.currentProject);
- const [isLoading, setIsLoading] = useState(false);
- const [subagentsWithScope, setSubagentsWithScope] = useState([]);
- // Fetch filesystem agents
- const fetchFilesystemAgents = useCallback(async () => {
- setIsLoading(true);
- try {
- const api = getElectronAPI();
- if (!api.settings) {
- console.warn('Settings API not available');
- return;
- }
- const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
+ // Use React Query hook for fetching agents
+ const {
+ data: agents = [],
+ isLoading,
+ refetch,
+ } = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
- if (data.success && data.agents) {
- // Transform filesystem agents to SubagentWithScope format
- const agents: SubagentWithScope[] = data.agents.map(
- ({ name, definition, source, filePath }: FilesystemAgent) => ({
- name,
- definition,
- scope: source === 'user' ? 'global' : 'project',
- type: 'filesystem' as const,
- source,
- filePath,
- })
- );
- setSubagentsWithScope(agents);
- }
- } catch (error) {
- console.error('Failed to fetch filesystem agents:', error);
- } finally {
- setIsLoading(false);
- }
- }, [currentProject?.path]);
+ // Transform agents to SubagentWithScope format
+ const subagentsWithScope = useMemo((): SubagentWithScope[] => {
+ return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
+ name,
+ definition,
+ scope: source === 'user' ? 'global' : 'project',
+ type: 'filesystem' as const,
+ source,
+ filePath,
+ }));
+ }, [agents]);
- // Fetch filesystem agents on mount and when project changes
- useEffect(() => {
- fetchFilesystemAgents();
- }, [fetchFilesystemAgents]);
+ // Refresh function that invalidates the query cache
+ const refreshFilesystemAgents = useCallback(async () => {
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
+ });
+ await refetch();
+ }, [queryClient, currentProject?.path, refetch]);
return {
subagentsWithScope,
isLoading,
hasProject: !!currentProject,
- refreshFilesystemAgents: fetchFilesystemAgents,
+ refreshFilesystemAgents,
};
}
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/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx
index 0ec718a3..4321b6d8 100644
--- a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx
+++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx
@@ -1,239 +1,79 @@
-import { useState, useCallback, useEffect, useRef } from 'react';
+import { useState, useCallback, useMemo } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { ProviderToggle } from './provider-toggle';
-import { getElectronAPI } from '@/lib/electron';
-import { createLogger } from '@automaker/utils/logger';
+import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
+import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
-const logger = createLogger('OpencodeSettings');
-const OPENCODE_PROVIDER_ID = 'opencode';
-const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
-const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
-
export function OpencodeSettingsTab() {
+ const queryClient = useQueryClient();
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
- setDynamicOpencodeModels,
- dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
- cachedOpencodeProviders,
- setCachedOpencodeProviders,
} = useAppStore();
- const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
- const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
- const [cliStatus, setCliStatus] = useState
(null);
- const [authStatus, setAuthStatus] = useState(null);
const [isSaving, setIsSaving] = useState(false);
- const providerRefreshSignatureRef = useRef('');
- // Phase 1: Load CLI status quickly on mount
- useEffect(() => {
- const checkOpencodeStatus = async () => {
- setIsCheckingOpencodeCli(true);
- try {
- const api = getElectronAPI();
- if (api?.setup?.getOpencodeStatus) {
- const result = await api.setup.getOpencodeStatus();
- setCliStatus({
- success: result.success,
- status: result.installed ? 'installed' : 'not_installed',
- method: result.auth?.method,
- version: result.version,
- path: result.path,
- recommendation: result.recommendation,
- installCommands: result.installCommands,
- });
- if (result.auth) {
- setAuthStatus({
- authenticated: result.auth.authenticated,
- method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
- hasApiKey: result.auth.hasApiKey,
- hasEnvApiKey: result.auth.hasEnvApiKey,
- hasOAuthToken: result.auth.hasOAuthToken,
- });
- }
- } else {
- setCliStatus({
- success: false,
- status: 'not_installed',
- recommendation: 'OpenCode CLI detection is only available in desktop mode.',
- });
- }
- } catch (error) {
- logger.error('Failed to check OpenCode CLI status:', error);
- setCliStatus({
- success: false,
- status: 'not_installed',
- error: error instanceof Error ? error.message : 'Unknown error',
- });
- } finally {
- setIsCheckingOpencodeCli(false);
- }
+ // React Query hooks for data fetching
+ const {
+ data: cliStatusData,
+ isLoading: isCheckingOpencodeCli,
+ refetch: refetchCliStatus,
+ } = useOpencodeCliStatus();
+
+ const isCliInstalled = cliStatusData?.installed ?? false;
+
+ const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
+
+ const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
+
+ // Transform CLI status to the expected format
+ const cliStatus = useMemo((): SharedCliStatus | null => {
+ if (!cliStatusData) return null;
+ return {
+ success: cliStatusData.success ?? false,
+ status: cliStatusData.installed ? 'installed' : 'not_installed',
+ method: cliStatusData.auth?.method,
+ version: cliStatusData.version,
+ path: cliStatusData.path,
+ recommendation: cliStatusData.recommendation,
+ installCommands: cliStatusData.installCommands,
};
- checkOpencodeStatus();
- }, []);
+ }, [cliStatusData]);
- // Phase 2: Load dynamic models and providers in background (only if not cached)
- useEffect(() => {
- const loadDynamicContent = async () => {
- const api = getElectronAPI();
- const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
-
- if (!isInstalled || !api?.setup) return;
-
- // Skip if already have cached data
- const needsProviders = cachedOpencodeProviders.length === 0;
- const needsModels = dynamicOpencodeModels.length === 0;
-
- if (!needsProviders && !needsModels) return;
-
- setIsLoadingDynamicModels(true);
- try {
- // Load providers if needed
- if (needsProviders && api.setup.getOpencodeProviders) {
- const providersResult = await api.setup.getOpencodeProviders();
- if (providersResult.success && providersResult.providers) {
- setCachedOpencodeProviders(providersResult.providers);
- }
- }
-
- // Load models if needed
- if (needsModels && api.setup.getOpencodeModels) {
- const modelsResult = await api.setup.getOpencodeModels();
- if (modelsResult.success && modelsResult.models) {
- setDynamicOpencodeModels(modelsResult.models);
- }
- }
- } catch (error) {
- logger.error('Failed to load dynamic content:', error);
- } finally {
- setIsLoadingDynamicModels(false);
- }
+ // Transform auth status to the expected format
+ const authStatus = useMemo((): OpencodeAuthStatus | null => {
+ if (!cliStatusData?.auth) return null;
+ return {
+ authenticated: cliStatusData.auth.authenticated,
+ method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
+ hasApiKey: cliStatusData.auth.hasApiKey,
+ hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
+ hasOAuthToken: cliStatusData.auth.hasOAuthToken,
+ error: cliStatusData.auth.error,
};
- loadDynamicContent();
- }, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
-
- useEffect(() => {
- const refreshModelsForNewProviders = async () => {
- const api = getElectronAPI();
- const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
-
- if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
- if (isLoadingDynamicModels) return;
-
- const authenticatedProviders = cachedOpencodeProviders
- .filter((provider) => provider.authenticated)
- .map((provider) => provider.id)
- .filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
-
- if (authenticatedProviders.length === 0) {
- providerRefreshSignatureRef.current = '';
- return;
- }
-
- const dynamicProviderIds = new Set(
- dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
- );
- const missingProviders = authenticatedProviders.filter(
- (providerId) => !dynamicProviderIds.has(providerId)
- );
-
- if (missingProviders.length === 0) {
- providerRefreshSignatureRef.current = '';
- return;
- }
-
- const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
- if (providerRefreshSignatureRef.current === signature) return;
- providerRefreshSignatureRef.current = signature;
-
- setIsLoadingDynamicModels(true);
- try {
- const modelsResult = await api.setup.refreshOpencodeModels();
- if (modelsResult.success && modelsResult.models) {
- setDynamicOpencodeModels(modelsResult.models);
- }
- } catch (error) {
- logger.error('Failed to refresh OpenCode models for new providers:', error);
- } finally {
- setIsLoadingDynamicModels(false);
- }
- };
-
- refreshModelsForNewProviders();
- }, [
- cachedOpencodeProviders,
- dynamicOpencodeModels,
- cliStatus?.success,
- cliStatus?.status,
- isLoadingDynamicModels,
- setDynamicOpencodeModels,
- ]);
+ }, [cliStatusData]);
+ // Refresh all opencode-related queries
const handleRefreshOpencodeCli = useCallback(async () => {
- setIsCheckingOpencodeCli(true);
- setIsLoadingDynamicModels(true);
- try {
- const api = getElectronAPI();
- if (api?.setup?.getOpencodeStatus) {
- const result = await api.setup.getOpencodeStatus();
- setCliStatus({
- success: result.success,
- status: result.installed ? 'installed' : 'not_installed',
- method: result.auth?.method,
- version: result.version,
- path: result.path,
- recommendation: result.recommendation,
- installCommands: result.installCommands,
- });
- if (result.auth) {
- setAuthStatus({
- authenticated: result.auth.authenticated,
- method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
- hasApiKey: result.auth.hasApiKey,
- hasEnvApiKey: result.auth.hasEnvApiKey,
- hasOAuthToken: result.auth.hasOAuthToken,
- });
- }
-
- if (result.installed) {
- // Refresh providers
- if (api?.setup?.getOpencodeProviders) {
- const providersResult = await api.setup.getOpencodeProviders();
- if (providersResult.success && providersResult.providers) {
- setCachedOpencodeProviders(providersResult.providers);
- }
- }
-
- // Refresh dynamic models
- if (api?.setup?.refreshOpencodeModels) {
- const modelsResult = await api.setup.refreshOpencodeModels();
- if (modelsResult.success && modelsResult.models) {
- setDynamicOpencodeModels(modelsResult.models);
- }
- }
-
- toast.success('OpenCode CLI refreshed');
- }
- }
- } catch (error) {
- logger.error('Failed to refresh OpenCode CLI status:', error);
- toast.error('Failed to refresh OpenCode CLI status');
- } finally {
- setIsCheckingOpencodeCli(false);
- setIsLoadingDynamicModels(false);
- }
- }, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
+ ]);
+ await refetchCliStatus();
+ toast.success('OpenCode CLI refreshed');
+ }, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -241,7 +81,7 @@ export function OpencodeSettingsTab() {
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
- } catch (error) {
+ } catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
@@ -255,7 +95,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
- } catch (error) {
+ } catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
@@ -269,7 +109,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
- } catch (error) {
+ } catch {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
@@ -287,7 +127,7 @@ export function OpencodeSettingsTab() {
);
}
- const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
+ const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
return (
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() {
@@ -310,8 +150,8 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
- providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
- dynamicModels={dynamicOpencodeModels}
+ providers={providersData as OpenCodeProviderInfo[]}
+ dynamicModels={modelsData}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}
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-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
index 30a8150f..6cf7bf50 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts
@@ -10,6 +10,7 @@ import { createElement } from 'react';
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
import type { FeatureCount } from '../types';
import type { SpecRegenerationEvent } from '@/types/electron';
+import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations';
interface UseSpecGenerationOptions {
loadSpec: () => Promise;
@@ -18,6 +19,11 @@ interface UseSpecGenerationOptions {
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const { currentProject } = useAppStore();
+ // React Query mutations
+ const createSpecMutation = useCreateSpec(currentProject?.path ?? '');
+ const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? '');
+ const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? '');
+
// Dialog visibility state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
@@ -427,47 +433,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
- try {
- const api = getElectronAPI();
- if (!api.specRegeneration) {
- logger.error('[useSpecGeneration] Spec regeneration not available');
- setIsCreating(false);
- return;
- }
- const result = await api.specRegeneration.create(
- currentProject.path,
- projectOverview.trim(),
- generateFeatures,
- analyzeProjectOnCreate,
- generateFeatures ? featureCountOnCreate : undefined
- );
- if (!result.success) {
- const errorMsg = result.error || 'Unknown error';
- logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
- setIsCreating(false);
- setCurrentPhase('error');
- setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
- logsRef.current = errorLog;
- setLogs(errorLog);
+ createSpecMutation.mutate(
+ {
+ projectOverview: projectOverview.trim(),
+ generateFeatures,
+ analyzeProject: analyzeProjectOnCreate,
+ featureCount: generateFeatures ? featureCountOnCreate : undefined,
+ },
+ {
+ onError: (error) => {
+ const errorMsg = error.message;
+ logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
+ setIsCreating(false);
+ setCurrentPhase('error');
+ setErrorMessage(errorMsg);
+ const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
+ logsRef.current = errorLog;
+ setLogs(errorLog);
+ },
}
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
- setIsCreating(false);
- setCurrentPhase('error');
- setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
- logsRef.current = errorLog;
- setLogs(errorLog);
- }
+ );
}, [
currentProject,
projectOverview,
generateFeatures,
analyzeProjectOnCreate,
featureCountOnCreate,
+ createSpecMutation,
]);
const handleRegenerate = useCallback(async () => {
@@ -483,47 +476,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
generateFeaturesOnRegenerate
);
- try {
- const api = getElectronAPI();
- if (!api.specRegeneration) {
- logger.error('[useSpecGeneration] Spec regeneration not available');
- setIsRegenerating(false);
- return;
- }
- const result = await api.specRegeneration.generate(
- currentProject.path,
- projectDefinition.trim(),
- generateFeaturesOnRegenerate,
- analyzeProjectOnRegenerate,
- generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
- );
- if (!result.success) {
- const errorMsg = result.error || 'Unknown error';
- logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
- setIsRegenerating(false);
- setCurrentPhase('error');
- setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
- logsRef.current = errorLog;
- setLogs(errorLog);
+ regenerateSpecMutation.mutate(
+ {
+ projectDefinition: projectDefinition.trim(),
+ generateFeatures: generateFeaturesOnRegenerate,
+ analyzeProject: analyzeProjectOnRegenerate,
+ featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
+ },
+ {
+ onError: (error) => {
+ const errorMsg = error.message;
+ logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
+ setIsRegenerating(false);
+ setCurrentPhase('error');
+ setErrorMessage(errorMsg);
+ const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
+ logsRef.current = errorLog;
+ setLogs(errorLog);
+ },
}
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
- setIsRegenerating(false);
- setCurrentPhase('error');
- setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
- logsRef.current = errorLog;
- setLogs(errorLog);
- }
+ );
}, [
currentProject,
projectDefinition,
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
featureCountOnRegenerate,
+ regenerateSpecMutation,
]);
const handleGenerateFeatures = useCallback(async () => {
@@ -536,36 +516,20 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
- try {
- const api = getElectronAPI();
- if (!api.specRegeneration) {
- logger.error('[useSpecGeneration] Spec regeneration not available');
- setIsGeneratingFeatures(false);
- return;
- }
- const result = await api.specRegeneration.generateFeatures(currentProject.path);
- if (!result.success) {
- const errorMsg = result.error || 'Unknown error';
- logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
+ generateFeaturesMutation.mutate(undefined, {
+ onError: (error) => {
+ const errorMsg = error.message;
+ logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
+ const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
- }
- } catch (error) {
- const errorMsg = error instanceof Error ? error.message : String(error);
- logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
- setIsGeneratingFeatures(false);
- setCurrentPhase('error');
- setErrorMessage(errorMsg);
- const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
- logsRef.current = errorLog;
- setLogs(errorLog);
- }
- }, [currentProject]);
+ },
+ });
+ }, [currentProject, generateFeaturesMutation]);
const handleSync = useCallback(async () => {
if (!currentProject) return;
diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
index 9fc09b81..5aff3df4 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts
@@ -1,62 +1,51 @@
import { useEffect, useState, useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
-
-const logger = createLogger('SpecLoading');
-import { getElectronAPI } from '@/lib/electron';
+import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
+import { useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/query-keys';
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
- const [isLoading, setIsLoading] = useState(true);
+ const queryClient = useQueryClient();
const [specExists, setSpecExists] = useState(true);
- const [isGenerationRunning, setIsGenerationRunning] = useState(false);
- const loadSpec = useCallback(async () => {
- if (!currentProject) return;
+ // React Query hooks
+ const specFileQuery = useSpecFile(currentProject?.path);
+ const statusQuery = useSpecRegenerationStatus(currentProject?.path);
- setIsLoading(true);
- try {
- const api = getElectronAPI();
-
- // Check if spec generation is running
- if (api.specRegeneration) {
- const status = await api.specRegeneration.status(currentProject.path);
- if (status.success && status.isRunning) {
- logger.debug('Spec generation is running for this project');
- setIsGenerationRunning(true);
- } else {
- setIsGenerationRunning(false);
- }
- } else {
- setIsGenerationRunning(false);
- }
-
- // Always try to load the spec file, even if generation is running
- // This allows users to view their existing spec while generating features
- const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
-
- if (result.success && result.content) {
- setAppSpec(result.content);
- setSpecExists(true);
- } else {
- // File doesn't exist
- setAppSpec('');
- setSpecExists(false);
- }
- } catch (error) {
- logger.error('Failed to load spec:', error);
- setSpecExists(false);
- } finally {
- setIsLoading(false);
- }
- }, [currentProject, setAppSpec]);
+ const isGenerationRunning = statusQuery.data?.isRunning ?? false;
+ // Update app store and specExists when spec file data changes
useEffect(() => {
- loadSpec();
- }, [loadSpec]);
+ if (specFileQuery.data && !isGenerationRunning) {
+ setAppSpec(specFileQuery.data.content);
+ setSpecExists(specFileQuery.data.exists);
+ }
+ }, [specFileQuery.data, setAppSpec, isGenerationRunning]);
+
+ // Manual reload function (invalidates cache)
+ const loadSpec = useCallback(async () => {
+ if (!currentProject?.path) return;
+
+ // Fetch fresh status data to avoid stale cache issues
+ // Using fetchQuery ensures we get the latest data before checking
+ const statusData = await queryClient.fetchQuery<{ isRunning: boolean }>({
+ queryKey: queryKeys.specRegeneration.status(currentProject.path),
+ staleTime: 0, // Force fresh fetch
+ });
+
+ if (statusData?.isRunning) {
+ return;
+ }
+
+ // Invalidate and refetch spec file
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.spec.file(currentProject.path),
+ });
+ }, [currentProject?.path, queryClient]);
return {
- isLoading,
+ isLoading: specFileQuery.isLoading,
specExists,
setSpecExists,
isGenerationRunning,
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/hooks/use-spec-save.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
index 5b0bbb47..03812fd3 100644
--- a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
+++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts
@@ -1,28 +1,20 @@
import { useState } from 'react';
-import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
-
-const logger = createLogger('SpecSave');
-import { getElectronAPI } from '@/lib/electron';
+import { useSaveSpec } from '@/hooks/mutations';
export function useSpecSave() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
- const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
+ // React Query mutation
+ const saveMutation = useSaveSpec(currentProject?.path ?? '');
+
const saveSpec = async () => {
if (!currentProject) return;
- setIsSaving(true);
- try {
- const api = getElectronAPI();
- await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec);
- setHasChanges(false);
- } catch (error) {
- logger.error('Failed to save spec:', error);
- } finally {
- setIsSaving(false);
- }
+ saveMutation.mutate(appSpec, {
+ onSuccess: () => setHasChanges(false),
+ });
};
const handleChange = (value: string) => {
@@ -31,7 +23,7 @@ export function useSpecSave() {
};
return {
- isSaving,
+ isSaving: saveMutation.isPending,
hasChanges,
setHasChanges,
saveSpec,
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/contexts/file-browser-context.tsx b/apps/ui/src/contexts/file-browser-context.tsx
index 959ba86b..74dc9200 100644
--- a/apps/ui/src/contexts/file-browser-context.tsx
+++ b/apps/ui/src/contexts/file-browser-context.tsx
@@ -67,9 +67,25 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
);
}
+// No-op fallback for HMR transitions when context temporarily becomes unavailable
+const hmrFallback: FileBrowserContextValue = {
+ openFileBrowser: async () => {
+ console.warn('[HMR] FileBrowserContext not available, returning null');
+ return null;
+ },
+};
+
export function useFileBrowser() {
const context = useContext(FileBrowserContext);
+ // During HMR, the context can temporarily be null as modules reload.
+ // Instead of crashing the app, return a safe no-op fallback that will
+ // be replaced once the provider re-mounts.
if (!context) {
+ if (import.meta.hot) {
+ // In development with HMR active, gracefully degrade
+ return hmrFallback;
+ }
+ // In production, this indicates a real bug - throw to help debug
throw new Error('useFileBrowser must be used within FileBrowserProvider');
}
return context;
diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts
new file mode 100644
index 00000000..9cab4bea
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/index.ts
@@ -0,0 +1,79 @@
+/**
+ * Mutations Barrel Export
+ *
+ * Central export point for all React Query mutations.
+ *
+ * @example
+ * ```tsx
+ * import { useCreateFeature, useStartFeature, useCommitWorktree } from '@/hooks/mutations';
+ * ```
+ */
+
+// Feature mutations
+export {
+ useCreateFeature,
+ useUpdateFeature,
+ useDeleteFeature,
+ useGenerateTitle,
+ useBatchUpdateFeatures,
+} from './use-feature-mutations';
+
+// Auto mode mutations
+export {
+ useStartFeature,
+ useResumeFeature,
+ useStopFeature,
+ useVerifyFeature,
+ useApprovePlan,
+ useFollowUpFeature,
+ useCommitFeature,
+ useAnalyzeProject,
+ useStartAutoMode,
+ useStopAutoMode,
+} from './use-auto-mode-mutations';
+
+// Settings mutations
+export {
+ useUpdateGlobalSettings,
+ useUpdateProjectSettings,
+ useSaveCredentials,
+} from './use-settings-mutations';
+
+// Worktree mutations
+export {
+ useCreateWorktree,
+ useDeleteWorktree,
+ useCommitWorktree,
+ usePushWorktree,
+ usePullWorktree,
+ useCreatePullRequest,
+ useMergeWorktree,
+ useSwitchBranch,
+ useCheckoutBranch,
+ useGenerateCommitMessage,
+ useOpenInEditor,
+ useInitGit,
+ useSetInitScript,
+ useDeleteInitScript,
+} from './use-worktree-mutations';
+
+// GitHub mutations
+export {
+ useValidateIssue,
+ useMarkValidationViewed,
+ useGetValidationStatus,
+} from './use-github-mutations';
+
+// Ideation mutations
+export { useGenerateIdeationSuggestions } from './use-ideation-mutations';
+
+// Spec mutations
+export {
+ useCreateSpec,
+ useRegenerateSpec,
+ useGenerateFeatures,
+ useSaveSpec,
+} from './use-spec-mutations';
+
+// Cursor Permissions mutations
+export { useApplyCursorProfile, useCopyCursorConfig } from './use-cursor-permissions-mutations';
diff --git a/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
new file mode 100644
index 00000000..0eb07a1d
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
@@ -0,0 +1,388 @@
+/**
+ * Auto Mode Mutations
+ *
+ * React Query mutations for auto mode operations like running features,
+ * stopping features, and plan approval.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+
+/**
+ * Start running a feature in auto mode
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for starting a feature
+ *
+ * @example
+ * ```tsx
+ * const startFeature = useStartFeature(projectPath);
+ * startFeature.mutate({ featureId: 'abc123', useWorktrees: true });
+ * ```
+ */
+export function useStartFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ featureId,
+ useWorktrees,
+ worktreePath,
+ }: {
+ featureId: string;
+ useWorktrees?: boolean;
+ worktreePath?: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.runFeature(
+ projectPath,
+ featureId,
+ useWorktrees,
+ worktreePath
+ );
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start feature');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to start feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Resume a paused or interrupted feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for resuming a feature
+ */
+export function useResumeFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ featureId,
+ useWorktrees,
+ }: {
+ featureId: string;
+ useWorktrees?: boolean;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.resumeFeature(projectPath, featureId, useWorktrees);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to resume feature');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to resume feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Stop a running feature
+ *
+ * @returns Mutation for stopping a feature
+ *
+ * @example
+ * ```tsx
+ * const stopFeature = useStopFeature();
+ * // Simple stop
+ * stopFeature.mutate('feature-id');
+ * // Stop with project path for cache invalidation
+ * stopFeature.mutate({ featureId: 'feature-id', projectPath: '/path/to/project' });
+ * ```
+ */
+export function useStopFeature() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (input: string | { featureId: string; projectPath?: string }) => {
+ const featureId = typeof input === 'string' ? input : input.featureId;
+ const api = getElectronAPI();
+ const result = await api.autoMode.stopFeature(featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to stop feature');
+ }
+ // Return projectPath for use in onSuccess
+ return { ...result, projectPath: typeof input === 'string' ? undefined : input.projectPath };
+ },
+ onSuccess: (data) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ // Also invalidate features cache if projectPath is provided
+ if (data.projectPath) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(data.projectPath) });
+ }
+ toast.success('Feature stopped');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to stop feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Verify a completed feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for verifying a feature
+ */
+export function useVerifyFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (featureId: string) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.verifyFeature(projectPath, featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to verify feature');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to verify feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Approve or reject a plan
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for plan approval
+ *
+ * @example
+ * ```tsx
+ * const approvePlan = useApprovePlan(projectPath);
+ * approvePlan.mutate({ featureId: 'abc', approved: true });
+ * ```
+ */
+export function useApprovePlan(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ featureId,
+ approved,
+ editedPlan,
+ feedback,
+ }: {
+ featureId: string;
+ approved: boolean;
+ editedPlan?: string;
+ feedback?: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.approvePlan(
+ projectPath,
+ featureId,
+ approved,
+ editedPlan,
+ feedback
+ );
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to submit plan decision');
+ }
+ return result;
+ },
+ onSuccess: (_, { approved }) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ if (approved) {
+ toast.success('Plan approved');
+ } else {
+ toast.info('Plan rejected');
+ }
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to submit plan decision', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Send a follow-up prompt to a feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for sending follow-up
+ */
+export function useFollowUpFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ featureId,
+ prompt,
+ imagePaths,
+ useWorktrees,
+ }: {
+ featureId: string;
+ prompt: string;
+ imagePaths?: string[];
+ useWorktrees?: boolean;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.followUpFeature(
+ projectPath,
+ featureId,
+ prompt,
+ imagePaths,
+ useWorktrees
+ );
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to send follow-up');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to send follow-up', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Commit feature changes
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for committing feature
+ */
+export function useCommitFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (featureId: string) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.commitFeature(projectPath, featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to commit changes');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
+ toast.success('Changes committed');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to commit changes', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Analyze project structure
+ *
+ * @returns Mutation for project analysis
+ */
+export function useAnalyzeProject() {
+ return useMutation({
+ mutationFn: async (projectPath: string) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.analyzeProject(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to analyze project');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ toast.success('Project analysis started');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to analyze project', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Start auto mode for all pending features
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for starting auto mode
+ */
+export function useStartAutoMode(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (maxConcurrency?: number) => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.start(projectPath, maxConcurrency);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start auto mode');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ toast.success('Auto mode started');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to start auto mode', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Stop auto mode for all features
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for stopping auto mode
+ */
+export function useStopAutoMode(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.autoMode.stop(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to stop auto mode');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
+ toast.success('Auto mode stopped');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to stop auto mode', {
+ description: error.message,
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts
new file mode 100644
index 00000000..3b813d2e
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts
@@ -0,0 +1,96 @@
+/**
+ * Cursor Permissions Mutation Hooks
+ *
+ * React Query mutations for managing Cursor CLI permissions.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+
+interface ApplyProfileInput {
+ profileId: 'strict' | 'development';
+ scope: 'global' | 'project';
+}
+
+/**
+ * Apply a Cursor permission profile
+ *
+ * @param projectPath - Optional path to the project (required for project scope)
+ * @returns Mutation for applying permission profiles
+ *
+ * @example
+ * ```tsx
+ * const applyMutation = useApplyCursorProfile(projectPath);
+ * applyMutation.mutate({ profileId: 'development', scope: 'project' });
+ * ```
+ */
+export function useApplyCursorProfile(projectPath?: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (input: ApplyProfileInput) => {
+ const { profileId, scope } = input;
+ const api = getHttpApiClient();
+ const result = await api.setup.applyCursorPermissionProfile(
+ profileId,
+ scope,
+ scope === 'project' ? projectPath : undefined
+ );
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to apply profile');
+ }
+
+ return result;
+ },
+ onSuccess: (result) => {
+ // Invalidate permissions cache
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.cursorPermissions.permissions(projectPath),
+ });
+ toast.success(result.message || 'Profile applied');
+ },
+ onError: (error) => {
+ toast.error('Failed to apply profile', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ },
+ });
+}
+
+/**
+ * Copy Cursor example config to clipboard
+ *
+ * @returns Mutation for copying config
+ *
+ * @example
+ * ```tsx
+ * const copyMutation = useCopyCursorConfig();
+ * copyMutation.mutate('development');
+ * ```
+ */
+export function useCopyCursorConfig() {
+ return useMutation({
+ mutationFn: async (profileId: 'strict' | 'development') => {
+ const api = getHttpApiClient();
+ const result = await api.setup.getCursorExampleConfig(profileId);
+
+ if (!result.success || !result.config) {
+ throw new Error(result.error || 'Failed to get config');
+ }
+
+ await navigator.clipboard.writeText(result.config);
+ return result;
+ },
+ onSuccess: () => {
+ toast.success('Config copied to clipboard');
+ },
+ onError: (error) => {
+ toast.error('Failed to copy config', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-feature-mutations.ts b/apps/ui/src/hooks/mutations/use-feature-mutations.ts
new file mode 100644
index 00000000..0b8c4e84
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-feature-mutations.ts
@@ -0,0 +1,267 @@
+/**
+ * Feature Mutations
+ *
+ * React Query mutations for creating, updating, and deleting features.
+ * Includes optimistic updates for better UX.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+import type { Feature } from '@/store/app-store';
+
+/**
+ * Create a new feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for creating a feature
+ *
+ * @example
+ * ```tsx
+ * const createFeature = useCreateFeature(projectPath);
+ * createFeature.mutate({ id: 'uuid', title: 'New Feature', ... });
+ * ```
+ */
+export function useCreateFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (feature: Feature) => {
+ const api = getElectronAPI();
+ const result = await api.features?.create(projectPath, feature);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to create feature');
+ }
+ return result.feature;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+ toast.success('Feature created');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to create feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Update an existing feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for updating a feature with optimistic updates
+ */
+export function useUpdateFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ featureId,
+ updates,
+ descriptionHistorySource,
+ enhancementMode,
+ preEnhancementDescription,
+ }: {
+ featureId: string;
+ updates: Partial
;
+ descriptionHistorySource?: 'enhance' | 'edit';
+ enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
+ preEnhancementDescription?: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.features?.update(
+ projectPath,
+ featureId,
+ updates,
+ descriptionHistorySource,
+ enhancementMode,
+ preEnhancementDescription
+ );
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to update feature');
+ }
+ return result.feature;
+ },
+ // Optimistic update
+ onMutate: async ({ featureId, updates }) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+
+ // Snapshot the previous value
+ const previousFeatures = queryClient.getQueryData(
+ queryKeys.features.all(projectPath)
+ );
+
+ // Optimistically update the cache
+ if (previousFeatures) {
+ queryClient.setQueryData(
+ queryKeys.features.all(projectPath),
+ previousFeatures.map((f) => (f.id === featureId ? { ...f, ...updates } : f))
+ );
+ }
+
+ return { previousFeatures };
+ },
+ onError: (error: Error, _, context) => {
+ // Rollback on error
+ if (context?.previousFeatures) {
+ queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
+ }
+ toast.error('Failed to update feature', {
+ description: error.message,
+ });
+ },
+ onSettled: () => {
+ // Always refetch after error or success
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+ },
+ });
+}
+
+/**
+ * Delete a feature
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for deleting a feature with optimistic updates
+ */
+export function useDeleteFeature(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (featureId: string) => {
+ const api = getElectronAPI();
+ const result = await api.features?.delete(projectPath, featureId);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to delete feature');
+ }
+ },
+ // Optimistic delete
+ onMutate: async (featureId) => {
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+
+ const previousFeatures = queryClient.getQueryData(
+ queryKeys.features.all(projectPath)
+ );
+
+ if (previousFeatures) {
+ queryClient.setQueryData(
+ queryKeys.features.all(projectPath),
+ previousFeatures.filter((f) => f.id !== featureId)
+ );
+ }
+
+ return { previousFeatures };
+ },
+ onError: (error: Error, _, context) => {
+ if (context?.previousFeatures) {
+ queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
+ }
+ toast.error('Failed to delete feature', {
+ description: error.message,
+ });
+ },
+ onSuccess: () => {
+ toast.success('Feature deleted');
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+ },
+ });
+}
+
+/**
+ * Generate a title for a feature description
+ *
+ * @returns Mutation for generating a title
+ */
+export function useGenerateTitle() {
+ return useMutation({
+ mutationFn: async (description: string) => {
+ const api = getElectronAPI();
+ const result = await api.features?.generateTitle(description);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to generate title');
+ }
+ return result.title ?? '';
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to generate title', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Batch update multiple features (for reordering)
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for batch updating features
+ */
+export function useBatchUpdateFeatures(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (updates: Array<{ featureId: string; updates: Partial }>) => {
+ const api = getElectronAPI();
+ const results = await Promise.all(
+ updates.map(({ featureId, updates: featureUpdates }) =>
+ api.features?.update(projectPath, featureId, featureUpdates)
+ )
+ );
+
+ const failed = results.filter((r) => !r?.success);
+ if (failed.length > 0) {
+ throw new Error(`Failed to update ${failed.length} features`);
+ }
+ },
+ // Optimistic batch update
+ onMutate: async (updates) => {
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+
+ const previousFeatures = queryClient.getQueryData(
+ queryKeys.features.all(projectPath)
+ );
+
+ if (previousFeatures) {
+ const updatesMap = new Map(updates.map((u) => [u.featureId, u.updates]));
+ queryClient.setQueryData(
+ queryKeys.features.all(projectPath),
+ previousFeatures.map((f) => {
+ const featureUpdates = updatesMap.get(f.id);
+ return featureUpdates ? { ...f, ...featureUpdates } : f;
+ })
+ );
+ }
+
+ return { previousFeatures };
+ },
+ onError: (error: Error, _, context) => {
+ if (context?.previousFeatures) {
+ queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
+ }
+ toast.error('Failed to update features', {
+ description: error.message,
+ });
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-github-mutations.ts b/apps/ui/src/hooks/mutations/use-github-mutations.ts
new file mode 100644
index 00000000..29395cb3
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-github-mutations.ts
@@ -0,0 +1,163 @@
+/**
+ * GitHub Mutation Hooks
+ *
+ * React Query mutations for GitHub operations like validating issues.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI, GitHubIssue, GitHubComment } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+import type { LinkedPRInfo, ModelId } from '@automaker/types';
+import { resolveModelString } from '@automaker/model-resolver';
+
+/**
+ * Input for validating a GitHub issue
+ */
+interface ValidateIssueInput {
+ issue: GitHubIssue;
+ model?: ModelId;
+ thinkingLevel?: number;
+ reasoningEffort?: string;
+ comments?: GitHubComment[];
+ linkedPRs?: LinkedPRInfo[];
+}
+
+/**
+ * Validate a GitHub issue with AI
+ *
+ * This mutation triggers an async validation process. Results are delivered
+ * via WebSocket events (issue_validation_complete, issue_validation_error).
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for validating issues
+ *
+ * @example
+ * ```tsx
+ * const validateMutation = useValidateIssue(projectPath);
+ *
+ * validateMutation.mutate({
+ * issue,
+ * model: 'sonnet',
+ * comments,
+ * linkedPRs,
+ * });
+ * ```
+ */
+export function useValidateIssue(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (input: ValidateIssueInput) => {
+ const { issue, model, thinkingLevel, reasoningEffort, comments, linkedPRs } = input;
+
+ const api = getElectronAPI();
+ if (!api.github?.validateIssue) {
+ throw new Error('Validation API not available');
+ }
+
+ const validationInput = {
+ issueNumber: issue.number,
+ issueTitle: issue.title,
+ issueBody: issue.body || '',
+ issueLabels: issue.labels.map((l) => l.name),
+ comments,
+ linkedPRs,
+ };
+
+ // Resolve model alias to canonical model identifier
+ const resolvedModel = model ? resolveModelString(model) : undefined;
+
+ const result = await api.github.validateIssue(
+ projectPath,
+ validationInput,
+ resolvedModel,
+ thinkingLevel,
+ reasoningEffort
+ );
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start validation');
+ }
+
+ return { issueNumber: issue.number };
+ },
+ onSuccess: (_, variables) => {
+ toast.info(`Starting validation for issue #${variables.issue.number}`, {
+ description: 'You will be notified when the analysis is complete',
+ });
+ },
+ onError: (error) => {
+ toast.error('Failed to validate issue', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ },
+ // Note: We don't invalidate queries here because the actual result
+ // comes through WebSocket events which handle cache invalidation
+ });
+}
+
+/**
+ * Mark a validation as viewed
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for marking validation as viewed
+ *
+ * @example
+ * ```tsx
+ * const markViewedMutation = useMarkValidationViewed(projectPath);
+ * markViewedMutation.mutate(issueNumber);
+ * ```
+ */
+export function useMarkValidationViewed(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (issueNumber: number) => {
+ const api = getElectronAPI();
+ if (!api.github?.markValidationViewed) {
+ throw new Error('Mark viewed API not available');
+ }
+
+ const result = await api.github.markValidationViewed(projectPath, issueNumber);
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to mark as viewed');
+ }
+
+ return { issueNumber };
+ },
+ onSuccess: () => {
+ // Invalidate validations cache to refresh the viewed state
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.github.validations(projectPath),
+ });
+ },
+ // Silent mutation - no toast needed for marking as viewed
+ });
+}
+
+/**
+ * Get running validation status
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for getting validation status (returns running issue numbers)
+ */
+export function useGetValidationStatus(projectPath: string) {
+ return useMutation({
+ mutationFn: async () => {
+ const api = getElectronAPI();
+ if (!api.github?.getValidationStatus) {
+ throw new Error('Validation status API not available');
+ }
+
+ const result = await api.github.getValidationStatus(projectPath);
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to get validation status');
+ }
+
+ return result.runningIssues ?? [];
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts
new file mode 100644
index 00000000..61841d9e
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts
@@ -0,0 +1,82 @@
+/**
+ * Ideation Mutation Hooks
+ *
+ * React Query mutations for ideation operations like generating suggestions.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+import type { IdeaCategory, IdeaSuggestion } from '@automaker/types';
+
+/**
+ * Input for generating ideation suggestions
+ */
+interface GenerateSuggestionsInput {
+ promptId: string;
+ category: IdeaCategory;
+}
+
+/**
+ * Result from generating suggestions
+ */
+interface GenerateSuggestionsResult {
+ suggestions: IdeaSuggestion[];
+ promptId: string;
+ category: IdeaCategory;
+}
+
+/**
+ * Generate ideation suggestions based on a prompt
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for generating suggestions
+ *
+ * @example
+ * ```tsx
+ * const generateMutation = useGenerateIdeationSuggestions(projectPath);
+ *
+ * generateMutation.mutate({
+ * promptId: 'prompt-1',
+ * category: 'ux',
+ * }, {
+ * onSuccess: (data) => {
+ * console.log('Generated', data.suggestions.length, 'suggestions');
+ * },
+ * });
+ * ```
+ */
+export function useGenerateIdeationSuggestions(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (input: GenerateSuggestionsInput): Promise => {
+ const { promptId, category } = input;
+
+ const api = getElectronAPI();
+ if (!api.ideation?.generateSuggestions) {
+ throw new Error('Ideation API not available');
+ }
+
+ const result = await api.ideation.generateSuggestions(projectPath, promptId, category);
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to generate suggestions');
+ }
+
+ return {
+ suggestions: result.suggestions ?? [],
+ promptId,
+ category,
+ };
+ },
+ onSuccess: () => {
+ // Invalidate ideation ideas cache
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.ideation.ideas(projectPath),
+ });
+ },
+ // Toast notifications are handled by the component since it has access to prompt title
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-settings-mutations.ts b/apps/ui/src/hooks/mutations/use-settings-mutations.ts
new file mode 100644
index 00000000..cabf3669
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-settings-mutations.ts
@@ -0,0 +1,160 @@
+/**
+ * Settings Mutations
+ *
+ * React Query mutations for updating global and project settings.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+
+interface UpdateGlobalSettingsOptions {
+ /** Show success toast (default: true) */
+ showSuccessToast?: boolean;
+}
+
+/**
+ * Update global settings
+ *
+ * @param options - Configuration options
+ * @returns Mutation for updating global settings
+ *
+ * @example
+ * ```tsx
+ * const mutation = useUpdateGlobalSettings();
+ * mutation.mutate({ enableSkills: true });
+ *
+ * // With custom success handling (no default toast)
+ * const mutation = useUpdateGlobalSettings({ showSuccessToast: false });
+ * mutation.mutate({ enableSkills: true }, {
+ * onSuccess: () => toast.success('Skills enabled'),
+ * });
+ * ```
+ */
+export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {}) {
+ const { showSuccessToast = true } = options;
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (settings: Record) => {
+ const api = getElectronAPI();
+ if (!api.settings) {
+ throw new Error('Settings API not available');
+ }
+ // Use updateGlobal for partial updates
+ const result = await api.settings.updateGlobal(settings);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to update settings');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.settings.global() });
+ if (showSuccessToast) {
+ toast.success('Settings saved');
+ }
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to save settings', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Update project settings
+ *
+ * @param projectPath - Optional path to the project (can also pass via mutation variables)
+ * @returns Mutation for updating project settings
+ */
+interface ProjectSettingsWithPath {
+ projectPath: string;
+ settings: Record;
+}
+
+export function useUpdateProjectSettings(projectPath?: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (variables: Record | ProjectSettingsWithPath) => {
+ // Support both call patterns:
+ // 1. useUpdateProjectSettings(projectPath) then mutate(settings)
+ // 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
+ let path: string;
+ let settings: Record;
+
+ if (
+ typeof variables === 'object' &&
+ 'projectPath' in variables &&
+ 'settings' in variables &&
+ typeof variables.projectPath === 'string' &&
+ typeof variables.settings === 'object'
+ ) {
+ path = variables.projectPath;
+ settings = variables.settings as Record;
+ } else if (projectPath) {
+ path = projectPath;
+ settings = variables as Record;
+ } else {
+ throw new Error('Project path is required');
+ }
+
+ const api = getElectronAPI();
+ if (!api.settings) {
+ throw new Error('Settings API not available');
+ }
+ const result = await api.settings.updateProject(path, settings);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to update project settings');
+ }
+ return { ...result, projectPath: path };
+ },
+ onSuccess: (data) => {
+ const path = data.projectPath || projectPath;
+ if (path) {
+ queryClient.invalidateQueries({ queryKey: queryKeys.settings.project(path) });
+ }
+ toast.success('Project settings saved');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to save project settings', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Save credentials (API keys)
+ *
+ * @returns Mutation for saving credentials
+ */
+export function useSaveCredentials() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (credentials: { anthropic?: string; google?: string; openai?: string }) => {
+ const api = getElectronAPI();
+ if (!api.settings) {
+ throw new Error('Settings API not available');
+ }
+ const result = await api.settings.updateCredentials({ apiKeys: credentials });
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to save credentials');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.settings.credentials() });
+ queryClient.invalidateQueries({ queryKey: queryKeys.cli.apiKeys() });
+ toast.success('Credentials saved');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to save credentials', {
+ description: error.message,
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-spec-mutations.ts b/apps/ui/src/hooks/mutations/use-spec-mutations.ts
new file mode 100644
index 00000000..a9e890c0
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-spec-mutations.ts
@@ -0,0 +1,184 @@
+/**
+ * Spec Mutation Hooks
+ *
+ * React Query mutations for spec operations like creating, regenerating, and saving.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+import type { FeatureCount } from '@/components/views/spec-view/types';
+
+/**
+ * Input for creating a spec
+ */
+interface CreateSpecInput {
+ projectOverview: string;
+ generateFeatures: boolean;
+ analyzeProject: boolean;
+ featureCount?: FeatureCount;
+}
+
+/**
+ * Input for regenerating a spec
+ */
+interface RegenerateSpecInput {
+ projectDefinition: string;
+ generateFeatures: boolean;
+ analyzeProject: boolean;
+ featureCount?: FeatureCount;
+}
+
+/**
+ * Create a new spec for a project
+ *
+ * This mutation triggers an async spec creation process. Progress and completion
+ * are delivered via WebSocket events (spec_regeneration_progress, spec_regeneration_complete).
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for creating specs
+ *
+ * @example
+ * ```tsx
+ * const createMutation = useCreateSpec(projectPath);
+ *
+ * createMutation.mutate({
+ * projectOverview: 'A todo app with...',
+ * generateFeatures: true,
+ * analyzeProject: true,
+ * featureCount: 50,
+ * });
+ * ```
+ */
+export function useCreateSpec(projectPath: string) {
+ return useMutation({
+ mutationFn: async (input: CreateSpecInput) => {
+ const { projectOverview, generateFeatures, analyzeProject, featureCount } = input;
+
+ const api = getElectronAPI();
+ if (!api.specRegeneration) {
+ throw new Error('Spec regeneration API not available');
+ }
+
+ const result = await api.specRegeneration.create(
+ projectPath,
+ projectOverview.trim(),
+ generateFeatures,
+ analyzeProject,
+ generateFeatures ? featureCount : undefined
+ );
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start spec creation');
+ }
+
+ return result;
+ },
+ // Toast/state updates are handled by the component since it tracks WebSocket events
+ });
+}
+
+/**
+ * Regenerate an existing spec
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for regenerating specs
+ */
+export function useRegenerateSpec(projectPath: string) {
+ return useMutation({
+ mutationFn: async (input: RegenerateSpecInput) => {
+ const { projectDefinition, generateFeatures, analyzeProject, featureCount } = input;
+
+ const api = getElectronAPI();
+ if (!api.specRegeneration) {
+ throw new Error('Spec regeneration API not available');
+ }
+
+ const result = await api.specRegeneration.generate(
+ projectPath,
+ projectDefinition.trim(),
+ generateFeatures,
+ analyzeProject,
+ generateFeatures ? featureCount : undefined
+ );
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start spec regeneration');
+ }
+
+ return result;
+ },
+ });
+}
+
+/**
+ * Generate features from existing spec
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for generating features
+ */
+export function useGenerateFeatures(projectPath: string) {
+ return useMutation({
+ mutationFn: async () => {
+ const api = getElectronAPI();
+ if (!api.specRegeneration) {
+ throw new Error('Spec regeneration API not available');
+ }
+
+ const result = await api.specRegeneration.generateFeatures(projectPath);
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to start feature generation');
+ }
+
+ return result;
+ },
+ });
+}
+
+/**
+ * Save spec file content
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for saving spec
+ *
+ * @example
+ * ```tsx
+ * const saveMutation = useSaveSpec(projectPath);
+ *
+ * saveMutation.mutate(specContent, {
+ * onSuccess: () => setHasChanges(false),
+ * });
+ * ```
+ */
+export function useSaveSpec(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (content: string) => {
+ // Guard against empty projectPath to prevent writing to invalid locations
+ if (!projectPath || projectPath.trim() === '') {
+ throw new Error('Invalid project path: cannot save spec without a valid project');
+ }
+
+ const api = getElectronAPI();
+
+ await api.writeFile(`${projectPath}/.automaker/app_spec.txt`, content);
+
+ return { content };
+ },
+ onSuccess: () => {
+ // Invalidate spec file cache
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.spec.file(projectPath),
+ });
+ toast.success('Spec saved');
+ },
+ onError: (error) => {
+ toast.error('Failed to save spec', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts
new file mode 100644
index 00000000..ec8dd6e0
--- /dev/null
+++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts
@@ -0,0 +1,480 @@
+/**
+ * Worktree Mutations
+ *
+ * React Query mutations for worktree operations like creating, deleting,
+ * committing, pushing, and creating pull requests.
+ */
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { toast } from 'sonner';
+
+/**
+ * Create a new worktree
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for creating a worktree
+ */
+export function useCreateWorktree(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ branchName, baseBranch }: { branchName: string; baseBranch?: string }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.create(projectPath, branchName, baseBranch);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to create worktree');
+ }
+ return result.worktree;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
+ toast.success('Worktree created');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to create worktree', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Delete a worktree
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for deleting a worktree
+ */
+export function useDeleteWorktree(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ worktreePath,
+ deleteBranch,
+ }: {
+ worktreePath: string;
+ deleteBranch?: boolean;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.delete(projectPath, worktreePath, deleteBranch);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to delete worktree');
+ }
+ return result.deleted;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
+ toast.success('Worktree deleted');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to delete worktree', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Commit changes in a worktree
+ *
+ * @returns Mutation for committing changes
+ */
+export function useCommitWorktree() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ worktreePath, message }: { worktreePath: string; message: string }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.commit(worktreePath, message);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to commit changes');
+ }
+ return result.result;
+ },
+ onSuccess: (_, { worktreePath }) => {
+ // Invalidate all worktree queries since we don't know the project path
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ toast.success('Changes committed');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to commit changes', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Push worktree branch to remote
+ *
+ * @returns Mutation for pushing changes
+ */
+export function usePushWorktree() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.push(worktreePath, force);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to push changes');
+ }
+ return result.result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ toast.success('Changes pushed to remote');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to push changes', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Pull changes from remote
+ *
+ * @returns Mutation for pulling changes
+ */
+export function usePullWorktree() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (worktreePath: string) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.pull(worktreePath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to pull changes');
+ }
+ return result.result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ toast.success('Changes pulled from remote');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to pull changes', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Create a pull request from a worktree
+ *
+ * @returns Mutation for creating a PR
+ */
+export function useCreatePullRequest() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ worktreePath,
+ options,
+ }: {
+ worktreePath: string;
+ options?: {
+ projectPath?: string;
+ commitMessage?: string;
+ prTitle?: string;
+ prBody?: string;
+ baseBranch?: string;
+ draft?: boolean;
+ };
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.createPR(worktreePath, options);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to create pull request');
+ }
+ return result.result;
+ },
+ onSuccess: (result) => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ queryClient.invalidateQueries({ queryKey: ['github', 'prs'] });
+ if (result?.prUrl) {
+ toast.success('Pull request created', {
+ description: `PR #${result.prNumber} created`,
+ action: {
+ label: 'Open',
+ onClick: () => {
+ const api = getElectronAPI();
+ api.openExternalLink(result.prUrl!);
+ },
+ },
+ });
+ } else if (result?.prAlreadyExisted) {
+ toast.info('Pull request already exists');
+ }
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to create pull request', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Merge a worktree branch into main
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for merging a feature
+ */
+export function useMergeWorktree(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ branchName,
+ worktreePath,
+ options,
+ }: {
+ branchName: string;
+ worktreePath: string;
+ options?: {
+ squash?: boolean;
+ message?: string;
+ };
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.mergeFeature(
+ projectPath,
+ branchName,
+ worktreePath,
+ options
+ );
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to merge feature');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
+ toast.success('Feature merged successfully');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to merge feature', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Switch to a different branch
+ *
+ * @returns Mutation for switching branches
+ */
+export function useSwitchBranch() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ worktreePath,
+ branchName,
+ }: {
+ worktreePath: string;
+ branchName: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.switchBranch(worktreePath, branchName);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to switch branch');
+ }
+ return result.result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ toast.success('Switched branch');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to switch branch', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Checkout a new branch
+ *
+ * @returns Mutation for creating and checking out a new branch
+ */
+export function useCheckoutBranch() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ worktreePath,
+ branchName,
+ }: {
+ worktreePath: string;
+ branchName: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.checkoutBranch(worktreePath, branchName);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to checkout branch');
+ }
+ return result.result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ toast.success('New branch created and checked out');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to checkout branch', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Generate a commit message from git diff
+ *
+ * @returns Mutation for generating a commit message
+ */
+export function useGenerateCommitMessage() {
+ return useMutation({
+ mutationFn: async (worktreePath: string) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.generateCommitMessage(worktreePath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to generate commit message');
+ }
+ return result.message ?? '';
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to generate commit message', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Open worktree in editor
+ *
+ * @returns Mutation for opening in editor
+ */
+export function useOpenInEditor() {
+ return useMutation({
+ mutationFn: async ({
+ worktreePath,
+ editorCommand,
+ }: {
+ worktreePath: string;
+ editorCommand?: string;
+ }) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.openInEditor(worktreePath, editorCommand);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to open in editor');
+ }
+ return result.result;
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to open in editor', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Initialize git in a project
+ *
+ * @returns Mutation for initializing git
+ */
+export function useInitGit() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (projectPath: string) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.initGit(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to initialize git');
+ }
+ return result.result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['worktrees'] });
+ queryClient.invalidateQueries({ queryKey: ['github'] });
+ toast.success('Git repository initialized');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to initialize git', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Set init script for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for setting init script
+ */
+export function useSetInitScript(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (content: string) => {
+ const api = getElectronAPI();
+ const result = await api.worktree.setInitScript(projectPath, content);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to save init script');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) });
+ toast.success('Init script saved');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to save init script', {
+ description: error.message,
+ });
+ },
+ });
+}
+
+/**
+ * Delete init script for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Mutation for deleting init script
+ */
+export function useDeleteInitScript(projectPath: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.worktree.deleteInitScript(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to delete init script');
+ }
+ return result;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) });
+ toast.success('Init script deleted');
+ },
+ onError: (error: Error) => {
+ toast.error('Failed to delete init script', {
+ description: error.message,
+ });
+ },
+ });
+}
diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts
new file mode 100644
index 00000000..18e38120
--- /dev/null
+++ b/apps/ui/src/hooks/queries/index.ts
@@ -0,0 +1,91 @@
+/**
+ * Query Hooks Barrel Export
+ *
+ * Central export point for all React Query hooks.
+ * Import from this file for cleaner imports across the app.
+ *
+ * @example
+ * ```tsx
+ * import { useFeatures, useGitHubIssues, useClaudeUsage } from '@/hooks/queries';
+ * ```
+ */
+
+// Features
+export { useFeatures, useFeature, useAgentOutput } from './use-features';
+
+// GitHub
+export {
+ useGitHubIssues,
+ useGitHubPRs,
+ useGitHubValidations,
+ useGitHubRemote,
+ useGitHubIssueComments,
+} from './use-github';
+
+// Usage
+export { useClaudeUsage, useCodexUsage } from './use-usage';
+
+// Running Agents
+export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
+
+// Worktrees
+export {
+ useWorktrees,
+ useWorktreeInfo,
+ useWorktreeStatus,
+ useWorktreeDiffs,
+ useWorktreeBranches,
+ useWorktreeInitScript,
+ useAvailableEditors,
+} from './use-worktrees';
+
+// Settings
+export {
+ useGlobalSettings,
+ useProjectSettings,
+ useSettingsStatus,
+ useCredentials,
+ useDiscoveredAgents,
+} from './use-settings';
+
+// Models
+export {
+ useAvailableModels,
+ useCodexModels,
+ useOpencodeModels,
+ useOpencodeProviders,
+ useModelProviders,
+} from './use-models';
+
+// CLI Status
+export {
+ useClaudeCliStatus,
+ useCursorCliStatus,
+ useCodexCliStatus,
+ useOpencodeCliStatus,
+ useGitHubCliStatus,
+ useApiKeysStatus,
+ usePlatformInfo,
+} from './use-cli-status';
+
+// Ideation
+export { useIdeationPrompts, useIdeas, useIdea } from './use-ideation';
+
+// Sessions
+export { useSessions, useSessionHistory, useSessionQueue } from './use-sessions';
+
+// Git
+export { useGitDiffs } from './use-git';
+
+// Pipeline
+export { usePipelineConfig } from './use-pipeline';
+
+// Spec
+export { useSpecFile, useSpecRegenerationStatus } from './use-spec';
+
+// Cursor Permissions
+export { useCursorPermissionsQuery } from './use-cursor-permissions';
+export type { CursorPermissionsData } from './use-cursor-permissions';
+
+// Workspace
+export { useWorkspaceDirectories } from './use-workspace';
diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts
new file mode 100644
index 00000000..71ea2ae9
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-cli-status.ts
@@ -0,0 +1,147 @@
+/**
+ * CLI Status Query Hooks
+ *
+ * React Query hooks for fetching CLI tool status (Claude, Cursor, Codex, etc.)
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+/**
+ * Fetch Claude CLI status
+ *
+ * @returns Query result with Claude CLI status
+ */
+export function useClaudeCliStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.claude(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getClaudeStatus();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch Claude status');
+ }
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch Cursor CLI status
+ *
+ * @returns Query result with Cursor CLI status
+ */
+export function useCursorCliStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.cursor(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getCursorStatus();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch Cursor status');
+ }
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch Codex CLI status
+ *
+ * @returns Query result with Codex CLI status
+ */
+export function useCodexCliStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.codex(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getCodexStatus();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch Codex status');
+ }
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch OpenCode CLI status
+ *
+ * @returns Query result with OpenCode CLI status
+ */
+export function useOpencodeCliStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.opencode(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getOpencodeStatus();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch OpenCode status');
+ }
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch GitHub CLI status
+ *
+ * @returns Query result with GitHub CLI status
+ */
+export function useGitHubCliStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.github(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getGhStatus();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch GitHub CLI status');
+ }
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch API keys status
+ *
+ * @returns Query result with API keys status
+ */
+export function useApiKeysStatus() {
+ return useQuery({
+ queryKey: queryKeys.cli.apiKeys(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getApiKeys();
+ return result;
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ });
+}
+
+/**
+ * Fetch platform info
+ *
+ * @returns Query result with platform info
+ */
+export function usePlatformInfo() {
+ return useQuery({
+ queryKey: queryKeys.cli.platform(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getPlatform();
+ if (!result.success) {
+ throw new Error('Failed to fetch platform info');
+ }
+ return result;
+ },
+ staleTime: Infinity, // Platform info never changes
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-cursor-permissions.ts b/apps/ui/src/hooks/queries/use-cursor-permissions.ts
new file mode 100644
index 00000000..5d2e24f0
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-cursor-permissions.ts
@@ -0,0 +1,58 @@
+/**
+ * Cursor Permissions Query Hooks
+ *
+ * React Query hooks for fetching Cursor CLI permissions.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { CursorPermissionProfile } from '@automaker/types';
+
+export interface CursorPermissionsData {
+ activeProfile: CursorPermissionProfile | null;
+ effectivePermissions: { allow: string[]; deny: string[] } | null;
+ hasProjectConfig: boolean;
+ availableProfiles: Array<{
+ id: string;
+ name: string;
+ description: string;
+ permissions: { allow: string[]; deny: string[] };
+ }>;
+}
+
+/**
+ * Fetch Cursor permissions for a project
+ *
+ * @param projectPath - Optional path to the project
+ * @param enabled - Whether to enable the query
+ * @returns Query result with permissions data
+ *
+ * @example
+ * ```tsx
+ * const { data: permissions, isLoading, refetch } = useCursorPermissions(projectPath);
+ * ```
+ */
+export function useCursorPermissionsQuery(projectPath?: string, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.cursorPermissions.permissions(projectPath),
+ queryFn: async (): Promise => {
+ const api = getHttpApiClient();
+ const result = await api.setup.getCursorPermissions(projectPath);
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to load permissions');
+ }
+
+ return {
+ activeProfile: result.activeProfile || null,
+ effectivePermissions: result.effectivePermissions || null,
+ hasProjectConfig: result.hasProjectConfig || false,
+ availableProfiles: result.availableProfiles || [],
+ };
+ },
+ enabled,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts
new file mode 100644
index 00000000..78db6101
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-features.ts
@@ -0,0 +1,136 @@
+/**
+ * Features Query Hooks
+ *
+ * React Query hooks for fetching and managing features data.
+ * These hooks replace manual useState/useEffect patterns with
+ * automatic caching, deduplication, and background refetching.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { Feature } from '@/store/app-store';
+
+const FEATURES_REFETCH_ON_FOCUS = false;
+const FEATURES_REFETCH_ON_RECONNECT = false;
+
+/**
+ * Fetch all features for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with features array
+ *
+ * @example
+ * ```tsx
+ * const { data: features, isLoading, error } = useFeatures(currentProject?.path);
+ * ```
+ */
+export function useFeatures(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.features.all(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.features?.getAll(projectPath);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch features');
+ }
+ return (result.features ?? []) as Feature[];
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.FEATURES,
+ refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
+ refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
+ });
+}
+
+interface UseFeatureOptions {
+ enabled?: boolean;
+ /** Override polling interval (ms). Use false to disable polling. */
+ pollingInterval?: number | false;
+}
+
+/**
+ * Fetch a single feature by ID
+ *
+ * @param projectPath - Path to the project
+ * @param featureId - ID of the feature to fetch
+ * @param options - Query options including enabled and polling interval
+ * @returns Query result with single feature
+ */
+export function useFeature(
+ projectPath: string | undefined,
+ featureId: string | undefined,
+ options: UseFeatureOptions = {}
+) {
+ const { enabled = true, pollingInterval } = options;
+
+ return useQuery({
+ queryKey: queryKeys.features.single(projectPath ?? '', featureId ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
+ const api = getElectronAPI();
+ const result = await api.features?.get(projectPath, featureId);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch feature');
+ }
+ return (result.feature as Feature) ?? null;
+ },
+ enabled: !!projectPath && !!featureId && enabled,
+ staleTime: STALE_TIMES.FEATURES,
+ refetchInterval: pollingInterval,
+ refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
+ refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
+ });
+}
+
+interface UseAgentOutputOptions {
+ enabled?: boolean;
+ /** Override polling interval (ms). Use false to disable polling. */
+ pollingInterval?: number | false;
+}
+
+/**
+ * Fetch agent output for a feature
+ *
+ * @param projectPath - Path to the project
+ * @param featureId - ID of the feature
+ * @param options - Query options including enabled and polling interval
+ * @returns Query result with agent output string
+ */
+export function useAgentOutput(
+ projectPath: string | undefined,
+ featureId: string | undefined,
+ options: UseAgentOutputOptions = {}
+) {
+ const { enabled = true, pollingInterval } = options;
+
+ return useQuery({
+ queryKey: queryKeys.features.agentOutput(projectPath ?? '', featureId ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
+ const api = getElectronAPI();
+ const result = await api.features?.getAgentOutput(projectPath, featureId);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch agent output');
+ }
+ return result.content ?? '';
+ },
+ enabled: !!projectPath && !!featureId && enabled,
+ staleTime: STALE_TIMES.AGENT_OUTPUT,
+ // Use provided polling interval or default behavior
+ refetchInterval:
+ pollingInterval !== undefined
+ ? pollingInterval
+ : (query) => {
+ // Only poll if we have data and it's not empty (indicating active task)
+ if (query.state.data && query.state.data.length > 0) {
+ return 5000; // 5 seconds
+ }
+ return false;
+ },
+ refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
+ refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-git.ts b/apps/ui/src/hooks/queries/use-git.ts
new file mode 100644
index 00000000..ef4be5ca
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-git.ts
@@ -0,0 +1,37 @@
+/**
+ * Git Query Hooks
+ *
+ * React Query hooks for git operations.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+/**
+ * Fetch git diffs for a project (main project, not worktree)
+ *
+ * @param projectPath - Path to the project
+ * @param enabled - Whether to enable the query
+ * @returns Query result with files and diff content
+ */
+export function useGitDiffs(projectPath: string | undefined, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.git.diffs(projectPath ?? ''),
+ queryFn: async () => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.git.getDiffs(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch diffs');
+ }
+ return {
+ files: result.files ?? [],
+ diff: result.diff ?? '',
+ };
+ },
+ enabled: !!projectPath && enabled,
+ staleTime: STALE_TIMES.WORKTREES,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-github.ts b/apps/ui/src/hooks/queries/use-github.ts
new file mode 100644
index 00000000..47c3de7c
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-github.ts
@@ -0,0 +1,184 @@
+/**
+ * GitHub Query Hooks
+ *
+ * React Query hooks for fetching GitHub issues, PRs, and validations.
+ */
+
+import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { GitHubIssue, GitHubPR, GitHubComment, IssueValidation } from '@/lib/electron';
+
+interface GitHubIssuesResult {
+ openIssues: GitHubIssue[];
+ closedIssues: GitHubIssue[];
+}
+
+interface GitHubPRsResult {
+ openPRs: GitHubPR[];
+ mergedPRs: GitHubPR[];
+}
+
+/**
+ * Fetch GitHub issues for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with open and closed issues
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading } = useGitHubIssues(currentProject?.path);
+ * const { openIssues, closedIssues } = data ?? { openIssues: [], closedIssues: [] };
+ * ```
+ */
+export function useGitHubIssues(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.github.issues(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.github.listIssues(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch issues');
+ }
+ return {
+ openIssues: result.openIssues ?? [],
+ closedIssues: result.closedIssues ?? [],
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.GITHUB,
+ });
+}
+
+/**
+ * Fetch GitHub PRs for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with open and merged PRs
+ */
+export function useGitHubPRs(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.github.prs(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.github.listPRs(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch PRs');
+ }
+ return {
+ openPRs: result.openPRs ?? [],
+ mergedPRs: result.mergedPRs ?? [],
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.GITHUB,
+ });
+}
+
+/**
+ * Fetch GitHub validations for a project
+ *
+ * @param projectPath - Path to the project
+ * @param issueNumber - Optional issue number to filter by
+ * @returns Query result with validations
+ */
+export function useGitHubValidations(projectPath: string | undefined, issueNumber?: number) {
+ return useQuery({
+ queryKey: issueNumber
+ ? queryKeys.github.validation(projectPath ?? '', issueNumber)
+ : queryKeys.github.validations(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.github.getValidations(projectPath, issueNumber);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch validations');
+ }
+ return result.validations ?? [];
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.GITHUB,
+ });
+}
+
+/**
+ * Check GitHub remote for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with remote info
+ */
+export function useGitHubRemote(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.github.remote(projectPath ?? ''),
+ queryFn: async () => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.github.checkRemote(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to check remote');
+ }
+ return {
+ hasRemote: result.hasRemote ?? false,
+ owner: result.owner,
+ repo: result.repo,
+ url: result.url,
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.GITHUB,
+ });
+}
+
+/**
+ * Fetch comments for a GitHub issue with pagination support
+ *
+ * Uses useInfiniteQuery for proper "load more" pagination.
+ *
+ * @param projectPath - Path to the project
+ * @param issueNumber - Issue number
+ * @returns Infinite query result with comments and pagination helpers
+ *
+ * @example
+ * ```tsx
+ * const {
+ * data,
+ * isLoading,
+ * isFetchingNextPage,
+ * hasNextPage,
+ * fetchNextPage,
+ * refetch,
+ * } = useGitHubIssueComments(projectPath, issueNumber);
+ *
+ * // Get all comments flattened
+ * const comments = data?.pages.flatMap(page => page.comments) ?? [];
+ * ```
+ */
+export function useGitHubIssueComments(
+ projectPath: string | undefined,
+ issueNumber: number | undefined
+) {
+ return useInfiniteQuery({
+ queryKey: queryKeys.github.issueComments(projectPath ?? '', issueNumber ?? 0),
+ queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
+ if (!projectPath || !issueNumber) throw new Error('Missing project path or issue number');
+ const api = getElectronAPI();
+ const result = await api.github.getIssueComments(projectPath, issueNumber, pageParam);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch comments');
+ }
+ return {
+ comments: (result.comments ?? []) as GitHubComment[],
+ totalCount: result.totalCount ?? 0,
+ hasNextPage: result.hasNextPage ?? false,
+ endCursor: result.endCursor as string | undefined,
+ };
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.endCursor : undefined),
+ enabled: !!projectPath && !!issueNumber,
+ staleTime: STALE_TIMES.GITHUB,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-ideation.ts b/apps/ui/src/hooks/queries/use-ideation.ts
new file mode 100644
index 00000000..aa2bd023
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-ideation.ts
@@ -0,0 +1,86 @@
+/**
+ * Ideation Query Hooks
+ *
+ * React Query hooks for fetching ideation prompts and ideas.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+/**
+ * Fetch ideation prompts
+ *
+ * @returns Query result with prompts and categories
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading, error } = useIdeationPrompts();
+ * const { prompts, categories } = data ?? { prompts: [], categories: [] };
+ * ```
+ */
+export function useIdeationPrompts() {
+ return useQuery({
+ queryKey: queryKeys.ideation.prompts(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.ideation?.getPrompts();
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch prompts');
+ }
+ return {
+ prompts: result.prompts ?? [],
+ categories: result.categories ?? [],
+ };
+ },
+ staleTime: STALE_TIMES.SETTINGS, // Prompts rarely change
+ });
+}
+
+/**
+ * Fetch ideas for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with ideas array
+ */
+export function useIdeas(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.ideation.ideas(projectPath ?? ''),
+ queryFn: async () => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.ideation?.listIdeas(projectPath);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch ideas');
+ }
+ return result.ideas ?? [];
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.FEATURES,
+ });
+}
+
+/**
+ * Fetch a single idea by ID
+ *
+ * @param projectPath - Path to the project
+ * @param ideaId - ID of the idea
+ * @returns Query result with single idea
+ */
+export function useIdea(projectPath: string | undefined, ideaId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.ideation.idea(projectPath ?? '', ideaId ?? ''),
+ queryFn: async () => {
+ if (!projectPath || !ideaId) throw new Error('Missing project path or idea ID');
+ const api = getElectronAPI();
+ const result = await api.ideation?.getIdea(projectPath, ideaId);
+ if (!result?.success) {
+ throw new Error(result?.error || 'Failed to fetch idea');
+ }
+ return result.idea;
+ },
+ enabled: !!projectPath && !!ideaId,
+ staleTime: STALE_TIMES.FEATURES,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-models.ts b/apps/ui/src/hooks/queries/use-models.ts
new file mode 100644
index 00000000..d917492b
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-models.ts
@@ -0,0 +1,134 @@
+/**
+ * Models Query Hooks
+ *
+ * React Query hooks for fetching available AI models.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+interface CodexModel {
+ id: string;
+ label: string;
+ description: string;
+ hasThinking: boolean;
+ supportsVision: boolean;
+ tier: 'premium' | 'standard' | 'basic';
+ isDefault: boolean;
+}
+
+interface OpencodeModel {
+ id: string;
+ name: string;
+ modelString: string;
+ provider: string;
+ description: string;
+ supportsTools: boolean;
+ supportsVision: boolean;
+ tier: string;
+ default?: boolean;
+}
+
+/**
+ * Fetch available models
+ *
+ * @returns Query result with available models
+ */
+export function useAvailableModels() {
+ return useQuery({
+ queryKey: queryKeys.models.available(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.model.getAvailable();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch available models');
+ }
+ return result.models ?? [];
+ },
+ staleTime: STALE_TIMES.MODELS,
+ });
+}
+
+/**
+ * Fetch Codex models
+ *
+ * @param refresh - Force refresh from server
+ * @returns Query result with Codex models
+ */
+export function useCodexModels(refresh = false) {
+ return useQuery({
+ queryKey: queryKeys.models.codex(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.codex.getModels(refresh);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch Codex models');
+ }
+ return (result.models ?? []) as CodexModel[];
+ },
+ staleTime: STALE_TIMES.MODELS,
+ });
+}
+
+/**
+ * Fetch OpenCode models
+ *
+ * @param refresh - Force refresh from server
+ * @returns Query result with OpenCode models
+ */
+export function useOpencodeModels(refresh = false) {
+ return useQuery({
+ queryKey: queryKeys.models.opencode(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.setup.getOpencodeModels(refresh);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch OpenCode models');
+ }
+ return (result.models ?? []) as OpencodeModel[];
+ },
+ staleTime: STALE_TIMES.MODELS,
+ });
+}
+
+/**
+ * Fetch OpenCode providers
+ *
+ * @returns Query result with OpenCode providers
+ */
+export function useOpencodeProviders() {
+ return useQuery({
+ queryKey: queryKeys.models.opencodeProviders(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.setup.getOpencodeProviders();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch OpenCode providers');
+ }
+ return result.providers ?? [];
+ },
+ staleTime: STALE_TIMES.MODELS,
+ });
+}
+
+/**
+ * Fetch model providers status
+ *
+ * @returns Query result with provider status
+ */
+export function useModelProviders() {
+ return useQuery({
+ queryKey: queryKeys.models.providers(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.model.checkProviders();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch providers');
+ }
+ return result.providers ?? {};
+ },
+ staleTime: STALE_TIMES.MODELS,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-pipeline.ts b/apps/ui/src/hooks/queries/use-pipeline.ts
new file mode 100644
index 00000000..916810d6
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-pipeline.ts
@@ -0,0 +1,39 @@
+/**
+ * Pipeline Query Hooks
+ *
+ * React Query hooks for fetching pipeline configuration.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { PipelineConfig } from '@/store/app-store';
+
+/**
+ * Fetch pipeline config for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with pipeline config
+ *
+ * @example
+ * ```tsx
+ * const { data: pipelineConfig, isLoading } = usePipelineConfig(currentProject?.path);
+ * ```
+ */
+export function usePipelineConfig(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.pipeline.config(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getHttpApiClient();
+ const result = await api.pipeline.getConfig(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch pipeline config');
+ }
+ return result.config ?? null;
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts
new file mode 100644
index 00000000..75002226
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-running-agents.ts
@@ -0,0 +1,66 @@
+/**
+ * Running Agents Query Hook
+ *
+ * React Query hook for fetching currently running agents.
+ * This data is invalidated by WebSocket events when agents start/stop.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI, type RunningAgent } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
+const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
+
+interface RunningAgentsResult {
+ agents: RunningAgent[];
+ count: number;
+}
+
+/**
+ * Fetch all currently running agents
+ *
+ * @returns Query result with running agents and total count
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading } = useRunningAgents();
+ * const { agents, count } = data ?? { agents: [], count: 0 };
+ * ```
+ */
+export function useRunningAgents() {
+ return useQuery({
+ queryKey: queryKeys.runningAgents.all(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.runningAgents.getAll();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch running agents');
+ }
+ return {
+ agents: result.runningAgents ?? [],
+ count: result.totalCount ?? 0,
+ };
+ },
+ staleTime: STALE_TIMES.RUNNING_AGENTS,
+ // Note: Don't use refetchInterval here - rely on WebSocket invalidation
+ // for real-time updates instead of polling
+ refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
+ refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Get running agents count
+ * This is a selector that derives count from the main query
+ *
+ * @returns Query result with just the count
+ */
+export function useRunningAgentsCount() {
+ const query = useRunningAgents();
+ return {
+ ...query,
+ data: query.data?.count ?? 0,
+ };
+}
diff --git a/apps/ui/src/hooks/queries/use-sessions.ts b/apps/ui/src/hooks/queries/use-sessions.ts
new file mode 100644
index 00000000..001968e1
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-sessions.ts
@@ -0,0 +1,86 @@
+/**
+ * Sessions Query Hooks
+ *
+ * React Query hooks for fetching session data.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { SessionListItem } from '@/types/electron';
+
+/**
+ * Fetch all sessions
+ *
+ * @param includeArchived - Whether to include archived sessions
+ * @returns Query result with sessions array
+ *
+ * @example
+ * ```tsx
+ * const { data: sessions, isLoading } = useSessions(false);
+ * ```
+ */
+export function useSessions(includeArchived = false) {
+ return useQuery({
+ queryKey: queryKeys.sessions.all(includeArchived),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.sessions.list(includeArchived);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch sessions');
+ }
+ return result.sessions ?? [];
+ },
+ staleTime: STALE_TIMES.SESSIONS,
+ });
+}
+
+/**
+ * Fetch session history
+ *
+ * @param sessionId - ID of the session
+ * @returns Query result with session messages
+ */
+export function useSessionHistory(sessionId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.sessions.history(sessionId ?? ''),
+ queryFn: async () => {
+ if (!sessionId) throw new Error('No session ID');
+ const api = getElectronAPI();
+ const result = await api.agent.getHistory(sessionId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch session history');
+ }
+ return {
+ messages: result.messages ?? [],
+ isRunning: result.isRunning ?? false,
+ };
+ },
+ enabled: !!sessionId,
+ staleTime: STALE_TIMES.FEATURES, // Session history changes during conversations
+ });
+}
+
+/**
+ * Fetch session message queue
+ *
+ * @param sessionId - ID of the session
+ * @returns Query result with queued messages
+ */
+export function useSessionQueue(sessionId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.sessions.queue(sessionId ?? ''),
+ queryFn: async () => {
+ if (!sessionId) throw new Error('No session ID');
+ const api = getElectronAPI();
+ const result = await api.agent.queueList(sessionId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch queue');
+ }
+ return result.queue ?? [];
+ },
+ enabled: !!sessionId,
+ staleTime: STALE_TIMES.RUNNING_AGENTS, // Queue changes frequently during use
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-settings.ts b/apps/ui/src/hooks/queries/use-settings.ts
new file mode 100644
index 00000000..cb77ff35
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-settings.ts
@@ -0,0 +1,123 @@
+/**
+ * Settings Query Hooks
+ *
+ * React Query hooks for fetching global and project settings.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { GlobalSettings, ProjectSettings } from '@automaker/types';
+
+/**
+ * Fetch global settings
+ *
+ * @returns Query result with global settings
+ *
+ * @example
+ * ```tsx
+ * const { data: settings, isLoading } = useGlobalSettings();
+ * ```
+ */
+export function useGlobalSettings() {
+ return useQuery({
+ queryKey: queryKeys.settings.global(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.settings.getGlobal();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch global settings');
+ }
+ return result.settings as GlobalSettings;
+ },
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
+
+/**
+ * Fetch project-specific settings
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with project settings
+ */
+export function useProjectSettings(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.settings.project(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.settings.getProject(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch project settings');
+ }
+ return result.settings as ProjectSettings;
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
+
+/**
+ * Fetch settings status (migration status, etc.)
+ *
+ * @returns Query result with settings status
+ */
+export function useSettingsStatus() {
+ return useQuery({
+ queryKey: queryKeys.settings.status(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.settings.getStatus();
+ return result;
+ },
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
+
+/**
+ * Fetch credentials status (masked API keys)
+ *
+ * @returns Query result with credentials info
+ */
+export function useCredentials() {
+ return useQuery({
+ queryKey: queryKeys.settings.credentials(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.settings.getCredentials();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch credentials');
+ }
+ return result.credentials;
+ },
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
+
+/**
+ * Discover agents for a project
+ *
+ * @param projectPath - Path to the project
+ * @param sources - Sources to search ('user' | 'project')
+ * @returns Query result with discovered agents
+ */
+export function useDiscoveredAgents(
+ projectPath: string | undefined,
+ sources?: Array<'user' | 'project'>
+) {
+ return useQuery({
+ // Include sources in query key so different source combinations have separate caches
+ queryKey: queryKeys.settings.agents(projectPath ?? '', sources),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.settings.discoverAgents(projectPath, sources);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to discover agents');
+ }
+ return result.agents ?? [];
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-spec.ts b/apps/ui/src/hooks/queries/use-spec.ts
new file mode 100644
index 00000000..c81dea34
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-spec.ts
@@ -0,0 +1,103 @@
+/**
+ * Spec Query Hooks
+ *
+ * React Query hooks for fetching spec file content and regeneration status.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+interface SpecFileResult {
+ content: string;
+ exists: boolean;
+}
+
+interface SpecRegenerationStatusResult {
+ isRunning: boolean;
+ currentPhase?: string;
+}
+
+/**
+ * Fetch spec file content for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with spec content and existence flag
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading } = useSpecFile(currentProject?.path);
+ * if (data?.exists) {
+ * console.log(data.content);
+ * }
+ * ```
+ */
+export function useSpecFile(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.spec.file(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+
+ const api = getElectronAPI();
+ const result = await api.readFile(`${projectPath}/.automaker/app_spec.txt`);
+
+ if (result.success && result.content) {
+ return {
+ content: result.content,
+ exists: true,
+ };
+ }
+
+ return {
+ content: '',
+ exists: false,
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
+
+/**
+ * Check spec regeneration status for a project
+ *
+ * @param projectPath - Path to the project
+ * @param enabled - Whether to enable the query (useful during regeneration)
+ * @returns Query result with regeneration status
+ *
+ * @example
+ * ```tsx
+ * const { data } = useSpecRegenerationStatus(projectPath, isRegenerating);
+ * if (data?.isRunning) {
+ * // Show loading indicator
+ * }
+ * ```
+ */
+export function useSpecRegenerationStatus(projectPath: string | undefined, enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.specRegeneration.status(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+
+ const api = getElectronAPI();
+ if (!api.specRegeneration) {
+ return { isRunning: false };
+ }
+
+ const status = await api.specRegeneration.status(projectPath);
+
+ if (status.success) {
+ return {
+ isRunning: status.isRunning ?? false,
+ currentPhase: status.currentPhase,
+ };
+ }
+
+ return { isRunning: false };
+ },
+ enabled: !!projectPath && enabled,
+ staleTime: 5000, // Check every 5 seconds when active
+ refetchInterval: enabled ? 5000 : false,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts
new file mode 100644
index 00000000..21f0267d
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-usage.ts
@@ -0,0 +1,83 @@
+/**
+ * Usage Query Hooks
+ *
+ * React Query hooks for fetching Claude and Codex API usage data.
+ * These hooks include automatic polling for real-time usage updates.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+import type { ClaudeUsage, CodexUsage } from '@/store/app-store';
+
+/** Polling interval for usage data (60 seconds) */
+const USAGE_POLLING_INTERVAL = 60 * 1000;
+const USAGE_REFETCH_ON_FOCUS = false;
+const USAGE_REFETCH_ON_RECONNECT = false;
+
+/**
+ * Fetch Claude API usage data
+ *
+ * @param enabled - Whether the query should run (default: true)
+ * @returns Query result with Claude usage data
+ *
+ * @example
+ * ```tsx
+ * const { data: usage, isLoading } = useClaudeUsage(isPopoverOpen);
+ * ```
+ */
+export function useClaudeUsage(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.usage.claude(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.claude.getUsage();
+ // Check if result is an error response
+ if ('error' in result) {
+ throw new Error(result.message || result.error);
+ }
+ return result;
+ },
+ enabled,
+ staleTime: STALE_TIMES.USAGE,
+ refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
+ // Keep previous data while refetching
+ placeholderData: (previousData) => previousData,
+ refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch Codex API usage data
+ *
+ * @param enabled - Whether the query should run (default: true)
+ * @returns Query result with Codex usage data
+ *
+ * @example
+ * ```tsx
+ * const { data: usage, isLoading } = useCodexUsage(isPopoverOpen);
+ * ```
+ */
+export function useCodexUsage(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.usage.codex(),
+ queryFn: async (): Promise => {
+ const api = getElectronAPI();
+ const result = await api.codex.getUsage();
+ // Check if result is an error response
+ if ('error' in result) {
+ throw new Error(result.message || result.error);
+ }
+ return result;
+ },
+ enabled,
+ staleTime: STALE_TIMES.USAGE,
+ refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
+ // Keep previous data while refetching
+ placeholderData: (previousData) => previousData,
+ refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-workspace.ts b/apps/ui/src/hooks/queries/use-workspace.ts
new file mode 100644
index 00000000..2001e2b7
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-workspace.ts
@@ -0,0 +1,42 @@
+/**
+ * Workspace Query Hooks
+ *
+ * React Query hooks for workspace operations.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+interface WorkspaceDirectory {
+ name: string;
+ path: string;
+}
+
+/**
+ * Fetch workspace directories
+ *
+ * @param enabled - Whether to enable the query
+ * @returns Query result with directories
+ *
+ * @example
+ * ```tsx
+ * const { data: directories, isLoading, error } = useWorkspaceDirectories(open);
+ * ```
+ */
+export function useWorkspaceDirectories(enabled = true) {
+ return useQuery({
+ queryKey: queryKeys.workspace.directories(),
+ queryFn: async (): Promise => {
+ const api = getHttpApiClient();
+ const result = await api.workspace.getDirectories();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to load directories');
+ }
+ return result.directories ?? [];
+ },
+ enabled,
+ staleTime: STALE_TIMES.SETTINGS,
+ });
+}
diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts
new file mode 100644
index 00000000..cc75dafe
--- /dev/null
+++ b/apps/ui/src/hooks/queries/use-worktrees.ts
@@ -0,0 +1,274 @@
+/**
+ * Worktrees Query Hooks
+ *
+ * React Query hooks for fetching worktree data.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import { STALE_TIMES } from '@/lib/query-client';
+
+const WORKTREE_REFETCH_ON_FOCUS = false;
+const WORKTREE_REFETCH_ON_RECONNECT = false;
+
+interface WorktreeInfo {
+ path: string;
+ branch: string;
+ isMain: boolean;
+ hasChanges?: boolean;
+ changedFilesCount?: number;
+ featureId?: string;
+ linkedToBranch?: string;
+}
+
+interface RemovedWorktree {
+ path: string;
+ branch: string;
+}
+
+interface WorktreesResult {
+ worktrees: WorktreeInfo[];
+ removedWorktrees: RemovedWorktree[];
+}
+
+/**
+ * Fetch all worktrees for a project
+ *
+ * @param projectPath - Path to the project
+ * @param includeDetails - Whether to include detailed info (default: true)
+ * @returns Query result with worktrees array and removed worktrees
+ *
+ * @example
+ * ```tsx
+ * const { data, isLoading, refetch } = useWorktrees(currentProject?.path);
+ * const worktrees = data?.worktrees ?? [];
+ * ```
+ */
+export function useWorktrees(projectPath: string | undefined, includeDetails = true) {
+ return useQuery({
+ queryKey: queryKeys.worktrees.all(projectPath ?? ''),
+ queryFn: async (): Promise => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.worktree.listAll(projectPath, includeDetails);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch worktrees');
+ }
+ return {
+ worktrees: result.worktrees ?? [],
+ removedWorktrees: result.removedWorktrees ?? [],
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.WORKTREES,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch worktree info for a specific feature
+ *
+ * @param projectPath - Path to the project
+ * @param featureId - ID of the feature
+ * @returns Query result with worktree info
+ */
+export function useWorktreeInfo(projectPath: string | undefined, featureId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.worktrees.single(projectPath ?? '', featureId ?? ''),
+ queryFn: async () => {
+ if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
+ const api = getElectronAPI();
+ const result = await api.worktree.getInfo(projectPath, featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch worktree info');
+ }
+ return result;
+ },
+ enabled: !!projectPath && !!featureId,
+ staleTime: STALE_TIMES.WORKTREES,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch worktree status for a specific feature
+ *
+ * @param projectPath - Path to the project
+ * @param featureId - ID of the feature
+ * @returns Query result with worktree status
+ */
+export function useWorktreeStatus(projectPath: string | undefined, featureId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.worktrees.status(projectPath ?? '', featureId ?? ''),
+ queryFn: async () => {
+ if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
+ const api = getElectronAPI();
+ const result = await api.worktree.getStatus(projectPath, featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch worktree status');
+ }
+ return result;
+ },
+ enabled: !!projectPath && !!featureId,
+ staleTime: STALE_TIMES.WORKTREES,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch worktree diffs for a specific feature
+ *
+ * @param projectPath - Path to the project
+ * @param featureId - ID of the feature
+ * @returns Query result with files and diff content
+ */
+export function useWorktreeDiffs(projectPath: string | undefined, featureId: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.worktrees.diffs(projectPath ?? '', featureId ?? ''),
+ queryFn: async () => {
+ if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
+ const api = getElectronAPI();
+ const result = await api.worktree.getDiffs(projectPath, featureId);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch diffs');
+ }
+ return {
+ files: result.files ?? [],
+ diff: result.diff ?? '',
+ };
+ },
+ enabled: !!projectPath && !!featureId,
+ staleTime: STALE_TIMES.WORKTREES,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+interface BranchInfo {
+ name: string;
+ isCurrent: boolean;
+ isRemote?: boolean;
+ lastCommit?: string;
+ upstream?: string;
+}
+
+interface BranchesResult {
+ branches: BranchInfo[];
+ aheadCount: number;
+ behindCount: number;
+ hasRemoteBranch: boolean;
+ isGitRepo: boolean;
+ hasCommits: boolean;
+}
+
+/**
+ * Fetch available branches for a worktree
+ *
+ * @param worktreePath - Path to the worktree
+ * @param includeRemote - Whether to include remote branches
+ * @returns Query result with branches, ahead/behind counts, and git repo status
+ */
+export function useWorktreeBranches(worktreePath: string | undefined, includeRemote = false) {
+ return useQuery({
+ // Include includeRemote in query key so different configurations have separate caches
+ queryKey: queryKeys.worktrees.branches(worktreePath ?? '', includeRemote),
+ queryFn: async (): Promise => {
+ if (!worktreePath) throw new Error('No worktree path');
+ const api = getElectronAPI();
+ const result = await api.worktree.listBranches(worktreePath, includeRemote);
+
+ // Handle special git status codes
+ if (result.code === 'NOT_GIT_REPO') {
+ return {
+ branches: [],
+ aheadCount: 0,
+ behindCount: 0,
+ hasRemoteBranch: false,
+ isGitRepo: false,
+ hasCommits: false,
+ };
+ }
+ if (result.code === 'NO_COMMITS') {
+ return {
+ branches: [],
+ aheadCount: 0,
+ behindCount: 0,
+ hasRemoteBranch: false,
+ isGitRepo: true,
+ hasCommits: false,
+ };
+ }
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch branches');
+ }
+
+ return {
+ branches: result.result?.branches ?? [],
+ aheadCount: result.result?.aheadCount ?? 0,
+ behindCount: result.result?.behindCount ?? 0,
+ hasRemoteBranch: result.result?.hasRemoteBranch ?? false,
+ isGitRepo: true,
+ hasCommits: true,
+ };
+ },
+ enabled: !!worktreePath,
+ staleTime: STALE_TIMES.WORKTREES,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch init script for a project
+ *
+ * @param projectPath - Path to the project
+ * @returns Query result with init script content
+ */
+export function useWorktreeInitScript(projectPath: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.worktrees.initScript(projectPath ?? ''),
+ queryFn: async () => {
+ if (!projectPath) throw new Error('No project path');
+ const api = getElectronAPI();
+ const result = await api.worktree.getInitScript(projectPath);
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch init script');
+ }
+ return {
+ exists: result.exists ?? false,
+ content: result.content ?? '',
+ };
+ },
+ enabled: !!projectPath,
+ staleTime: STALE_TIMES.SETTINGS,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
+
+/**
+ * Fetch available editors
+ *
+ * @returns Query result with available editors
+ */
+export function useAvailableEditors() {
+ return useQuery({
+ queryKey: queryKeys.worktrees.editors(),
+ queryFn: async () => {
+ const api = getElectronAPI();
+ const result = await api.worktree.getAvailableEditors();
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to fetch editors');
+ }
+ return result.editors ?? [];
+ },
+ staleTime: STALE_TIMES.CLI_STATUS,
+ refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
+ refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
+ });
+}
diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts
index fe23582f..2a337c50 100644
--- a/apps/ui/src/hooks/use-auto-mode.ts
+++ b/apps/ui/src/hooks/use-auto-mode.ts
@@ -1,13 +1,24 @@
import { useEffect, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { createLogger } from '@automaker/utils/logger';
+import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
+import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
const logger = createLogger('AutoMode');
-const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath';
+const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
+
+/**
+ * Generate a worktree key for session storage
+ * @param projectPath - The project path
+ * @param branchName - The branch name, or null for main worktree
+ */
+function getWorktreeSessionKey(projectPath: string, branchName: string | null): string {
+ return `${projectPath}::${branchName ?? '__main__'}`;
+}
function readAutoModeSession(): Record {
try {
@@ -31,9 +42,14 @@ function writeAutoModeSession(next: Record): void {
}
}
-function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void {
+function setAutoModeSessionForWorktree(
+ projectPath: string,
+ branchName: string | null,
+ running: boolean
+): void {
+ const worktreeKey = getWorktreeSessionKey(projectPath, branchName);
const current = readAutoModeSession();
- const next = { ...current, [projectPath]: running };
+ const next = { ...current, [worktreeKey]: running };
writeAutoModeSession(next);
}
@@ -45,33 +61,48 @@ function isPlanApprovalEvent(
}
/**
- * Hook for managing auto mode (scoped per project)
+ * Hook for managing auto mode (scoped per worktree)
+ * @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
*/
-export function useAutoMode() {
+export function useAutoMode(worktree?: WorktreeInfo) {
const {
- autoModeByProject,
+ autoModeByWorktree,
setAutoModeRunning,
addRunningTask,
removeRunningTask,
currentProject,
addAutoModeActivity,
- maxConcurrency,
projects,
setPendingPlanApproval,
+ getWorktreeKey,
+ getMaxConcurrencyForWorktree,
+ setMaxConcurrencyForWorktree,
+ isPrimaryWorktreeBranch,
} = useAppStore(
useShallow((state) => ({
- autoModeByProject: state.autoModeByProject,
+ autoModeByWorktree: state.autoModeByWorktree,
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
currentProject: state.currentProject,
addAutoModeActivity: state.addAutoModeActivity,
- maxConcurrency: state.maxConcurrency,
projects: state.projects,
setPendingPlanApproval: state.setPendingPlanApproval,
+ getWorktreeKey: state.getWorktreeKey,
+ getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
+ setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
+ isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
}))
);
+ // Derive branchName from worktree:
+ // If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch)
+ // If not provided, default to null (main worktree default)
+ const branchName = useMemo(() => {
+ if (!worktree) return null;
+ return worktree.isMain ? null : worktree.branch || null;
+ }, [worktree]);
+
// Helper to look up project ID from path
const getProjectIdFromPath = useCallback(
(path: string): string | undefined => {
@@ -81,37 +112,72 @@ export function useAutoMode() {
[projects]
);
- // Get project-specific auto mode state
+ // Get worktree-specific auto mode state
const projectId = currentProject?.id;
- const projectAutoModeState = useMemo(() => {
- if (!projectId) return { isRunning: false, runningTasks: [] };
- return autoModeByProject[projectId] || { isRunning: false, runningTasks: [] };
- }, [autoModeByProject, projectId]);
+ const worktreeAutoModeState = useMemo(() => {
+ if (!projectId)
+ return {
+ isRunning: false,
+ runningTasks: [],
+ branchName: null,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
+ };
+ const key = getWorktreeKey(projectId, branchName);
+ return (
+ autoModeByWorktree[key] || {
+ isRunning: false,
+ runningTasks: [],
+ branchName,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
+ }
+ );
+ }, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
- const isAutoModeRunning = projectAutoModeState.isRunning;
- const runningAutoTasks = projectAutoModeState.runningTasks;
+ const isAutoModeRunning = worktreeAutoModeState.isRunning;
+ const runningAutoTasks = worktreeAutoModeState.runningTasks;
+ const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
- // 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);
- }
- }, [currentProject, isAutoModeRunning, setAutoModeRunning]);
+ const result = await api.autoMode.status(currentProject.path, branchName);
+ if (result.success && result.isAutoLoopRunning !== undefined) {
+ const backendIsRunning = result.isAutoLoopRunning;
- // Handle auto mode events - listen globally for all projects
+ if (backendIsRunning !== isAutoModeRunning) {
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(
+ `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
+ );
+ setAutoModeRunning(
+ currentProject.id,
+ branchName,
+ backendIsRunning,
+ result.maxConcurrency,
+ result.runningFeatures
+ );
+ setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
+ }
+ }
+ } catch (error) {
+ logger.error('Error syncing auto mode state with backend:', error);
+ }
+ };
+
+ syncWithBackend();
+ }, [currentProject, branchName, setAutoModeRunning]);
+
+ // Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
@@ -119,8 +185,8 @@ export function useAutoMode() {
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
logger.info('Event:', event);
- // Events include projectPath from backend - use it to look up project ID
- // Fall back to current projectId if not provided in event
+ // Events include projectPath and branchName from backend
+ // Use them to look up project ID and determine the worktree
let eventProjectId: string | undefined;
if ('projectPath' in event && event.projectPath) {
eventProjectId = getProjectIdFromPath(event.projectPath);
@@ -132,6 +198,22 @@ export function useAutoMode() {
eventProjectId = projectId;
}
+ // Extract branchName from event, defaulting to null (main worktree)
+ const rawEventBranchName: string | null =
+ 'branchName' in event && event.branchName !== undefined ? event.branchName : null;
+
+ // Get projectPath for worktree lookup
+ const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
+
+ // Normalize branchName: convert primary worktree branch to null for consistent key lookup
+ // This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
+ const eventBranchName: string | null =
+ eventProjectPath &&
+ rawEventBranchName &&
+ isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
+ ? null
+ : rawEventBranchName;
+
// Skip event if we couldn't determine the project
if (!eventProjectId) {
logger.warn('Could not determine project for event:', event);
@@ -139,9 +221,56 @@ export function useAutoMode() {
}
switch (event.type) {
+ case 'auto_mode_started':
+ // Backend started auto loop - update UI state
+ {
+ const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
+ logger.info(`[AutoMode] Backend started auto loop for ${worktreeDesc}`);
+ if (eventProjectId) {
+ // Extract maxConcurrency from event if available, otherwise use current or default
+ const eventMaxConcurrency =
+ 'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
+ ? event.maxConcurrency
+ : getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
+ setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
+ }
+ }
+ break;
+
+ case 'auto_mode_resuming_features':
+ // Backend is resuming features from saved state
+ if (eventProjectId && 'features' in event && Array.isArray(event.features)) {
+ logger.info(`[AutoMode] Resuming ${event.features.length} feature(s) from saved state`);
+ // Use per-feature branchName if available, fallback to event-level branchName
+ event.features.forEach((feature: { id: string; branchName?: string | null }) => {
+ const featureBranchName = feature.branchName ?? eventBranchName;
+ addRunningTask(eventProjectId, featureBranchName, feature.id);
+ });
+ } else if (eventProjectId && 'featureIds' in event && Array.isArray(event.featureIds)) {
+ // Fallback for older event format without per-feature branchName
+ logger.info(
+ `[AutoMode] Resuming ${event.featureIds.length} feature(s) from saved state (legacy format)`
+ );
+ event.featureIds.forEach((featureId: string) => {
+ addRunningTask(eventProjectId, eventBranchName, featureId);
+ });
+ }
+ break;
+
+ case 'auto_mode_stopped':
+ // Backend stopped auto loop - update UI state
+ {
+ const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
+ logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
+ if (eventProjectId) {
+ setAutoModeRunning(eventProjectId, eventBranchName, false);
+ }
+ }
+ break;
+
case 'auto_mode_feature_start':
if (event.featureId) {
- addRunningTask(eventProjectId, event.featureId);
+ addRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: 'start',
@@ -154,7 +283,7 @@ export function useAutoMode() {
// Feature completed - remove from running tasks and UI will reload features on its own
if (event.featureId) {
logger.info('Feature completed:', event.featureId, 'passes:', event.passes);
- removeRunningTask(eventProjectId, event.featureId);
+ removeRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
featureId: event.featureId,
type: 'complete',
@@ -174,7 +303,7 @@ export function useAutoMode() {
logger.info('Feature cancelled/aborted:', event.error);
// Remove from running tasks
if (eventProjectId) {
- removeRunningTask(eventProjectId, event.featureId);
+ removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
break;
}
@@ -201,7 +330,7 @@ export function useAutoMode() {
// Remove the task from running since it failed
if (eventProjectId) {
- removeRunningTask(eventProjectId, event.featureId);
+ removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
}
break;
@@ -374,36 +503,101 @@ export function useAutoMode() {
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
+ setAutoModeRunning,
currentProject?.path,
+ getMaxConcurrencyForWorktree,
+ setMaxConcurrencyForWorktree,
+ isPrimaryWorktreeBranch,
]);
- // 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 for this worktree
+ 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}`);
- }, [currentProject, setAutoModeRunning, maxConcurrency]);
+ try {
+ const api = getElectronAPI();
+ if (!api?.autoMode?.start) {
+ throw new Error('Start auto mode API not available');
+ }
- // Stop auto mode - UI only, running tasks continue until natural completion
- const stop = useCallback(() => {
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
+
+ // Optimistically update UI state (backend will confirm via event)
+ const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
+ setAutoModeSessionForWorktree(currentProject.path, branchName, true);
+ setAutoModeRunning(currentProject.id, branchName, true, currentMaxConcurrency);
+
+ // Call backend to start the auto loop (pass current max concurrency)
+ const result = await api.autoMode.start(
+ currentProject.path,
+ branchName,
+ currentMaxConcurrency
+ );
+
+ if (!result.success) {
+ // Revert UI state on failure
+ setAutoModeSessionForWorktree(currentProject.path, branchName, false);
+ setAutoModeRunning(currentProject.id, branchName, false);
+ logger.error('Failed to start auto mode:', result.error);
+ throw new Error(result.error || 'Failed to start auto mode');
+ }
+
+ logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
+ } catch (error) {
+ // Revert UI state on error
+ setAutoModeSessionForWorktree(currentProject.path, branchName, false);
+ setAutoModeRunning(currentProject.id, branchName, false);
+ logger.error('Error starting auto mode:', error);
+ throw error;
+ }
+ }, [currentProject, branchName, setAutoModeRunning]);
+
+ // Stop auto mode - calls backend to stop the auto loop for this worktree
+ 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');
- }, [currentProject, setAutoModeRunning]);
+ try {
+ const api = getElectronAPI();
+ if (!api?.autoMode?.stop) {
+ throw new Error('Stop auto mode API not available');
+ }
+
+ const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
+ logger.info(`[AutoMode] Stopping auto loop for ${worktreeDesc} in ${currentProject.path}`);
+
+ // Optimistically update UI state (backend will confirm via event)
+ setAutoModeSessionForWorktree(currentProject.path, branchName, false);
+ setAutoModeRunning(currentProject.id, branchName, false);
+
+ // Call backend to stop the auto loop
+ const result = await api.autoMode.stop(currentProject.path, branchName);
+
+ if (!result.success) {
+ // Revert UI state on failure
+ setAutoModeSessionForWorktree(currentProject.path, branchName, true);
+ setAutoModeRunning(currentProject.id, branchName, true);
+ logger.error('Failed to stop auto mode:', result.error);
+ throw new Error(result.error || 'Failed to stop auto mode');
+ }
+
+ // NOTE: Running tasks will continue until natural completion.
+ // The backend stops picking up new features but doesn't abort running ones.
+ logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
+ } catch (error) {
+ // Revert UI state on error
+ setAutoModeSessionForWorktree(currentProject.path, branchName, true);
+ setAutoModeRunning(currentProject.id, branchName, true);
+ logger.error('Error stopping auto mode:', error);
+ throw error;
+ }
+ }, [currentProject, branchName, setAutoModeRunning]);
// Stop a specific feature
const stopFeature = useCallback(
@@ -422,7 +616,7 @@ export function useAutoMode() {
const result = await api.autoMode.stopFeature(featureId);
if (result.success) {
- removeRunningTask(currentProject.id, featureId);
+ removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
featureId,
@@ -439,7 +633,7 @@ export function useAutoMode() {
throw error;
}
},
- [currentProject, removeRunningTask, addAutoModeActivity]
+ [currentProject, branchName, removeRunningTask, addAutoModeActivity]
);
return {
@@ -447,6 +641,7 @@ export function useAutoMode() {
runningTasks: runningAutoTasks,
maxConcurrency,
canStartNewTask,
+ branchName,
start,
stop,
stopFeature,
diff --git a/apps/ui/src/hooks/use-board-background-settings.ts b/apps/ui/src/hooks/use-board-background-settings.ts
index fdb09b36..cde4f4b5 100644
--- a/apps/ui/src/hooks/use-board-background-settings.ts
+++ b/apps/ui/src/hooks/use-board-background-settings.ts
@@ -1,36 +1,30 @@
import { useCallback } from 'react';
-import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
-import { getHttpApiClient } from '@/lib/http-api-client';
-import { toast } from 'sonner';
-
-const logger = createLogger('BoardBackground');
+import { useUpdateProjectSettings } from '@/hooks/mutations';
/**
- * Hook for managing board background settings with automatic persistence to server
+ * Hook for managing board background settings with automatic persistence to server.
+ * Uses React Query mutation for server persistence with automatic error handling.
+ *
+ * For sliders, the modal uses local state during dragging and calls:
+ * - setCardOpacity/setColumnOpacity/setCardBorderOpacity to update store on commit
+ * - persistSettings directly to save to server on commit
*/
export function useBoardBackgroundSettings() {
const store = useAppStore();
- const httpClient = getHttpApiClient();
+
+ // Get the mutation without a fixed project path - we'll pass it with each call
+ const updateProjectSettings = useUpdateProjectSettings();
// Helper to persist settings to server
const persistSettings = useCallback(
- async (projectPath: string, settingsToUpdate: Record) => {
- try {
- const result = await httpClient.settings.updateProject(projectPath, {
- boardBackground: settingsToUpdate,
- });
-
- if (!result.success) {
- logger.error('Failed to persist settings:', result.error);
- toast.error('Failed to save settings');
- }
- } catch (error) {
- logger.error('Failed to persist settings:', error);
- toast.error('Failed to save settings');
- }
+ (projectPath: string, settingsToUpdate: Record) => {
+ updateProjectSettings.mutate({
+ projectPath,
+ settings: { boardBackground: settingsToUpdate },
+ });
},
- [httpClient]
+ [updateProjectSettings]
);
// Get current background settings for a project
@@ -75,22 +69,20 @@ export function useBoardBackgroundSettings() {
[store, persistSettings, getCurrentSettings]
);
+ // Update store (called on slider commit to update the board view)
const setCardOpacity = useCallback(
- async (projectPath: string, opacity: number) => {
- const current = getCurrentSettings(projectPath);
+ (projectPath: string, opacity: number) => {
store.setCardOpacity(projectPath, opacity);
- await persistSettings(projectPath, { ...current, cardOpacity: opacity });
},
- [store, persistSettings, getCurrentSettings]
+ [store]
);
+ // Update store (called on slider commit to update the board view)
const setColumnOpacity = useCallback(
- async (projectPath: string, opacity: number) => {
- const current = getCurrentSettings(projectPath);
+ (projectPath: string, opacity: number) => {
store.setColumnOpacity(projectPath, opacity);
- await persistSettings(projectPath, { ...current, columnOpacity: opacity });
},
- [store, persistSettings, getCurrentSettings]
+ [store]
);
const setColumnBorderEnabled = useCallback(
@@ -129,16 +121,12 @@ export function useBoardBackgroundSettings() {
[store, persistSettings, getCurrentSettings]
);
+ // Update store (called on slider commit to update the board view)
const setCardBorderOpacity = useCallback(
- async (projectPath: string, opacity: number) => {
- const current = getCurrentSettings(projectPath);
+ (projectPath: string, opacity: number) => {
store.setCardBorderOpacity(projectPath, opacity);
- await persistSettings(projectPath, {
- ...current,
- cardBorderOpacity: opacity,
- });
},
- [store, persistSettings, getCurrentSettings]
+ [store]
);
const setHideScrollbar = useCallback(
@@ -180,5 +168,6 @@ export function useBoardBackgroundSettings() {
setHideScrollbar,
clearBoardBackground,
getCurrentSettings,
+ persistSettings,
};
}
diff --git a/apps/ui/src/hooks/use-guided-prompts.ts b/apps/ui/src/hooks/use-guided-prompts.ts
index e192d6b3..e7d18e84 100644
--- a/apps/ui/src/hooks/use-guided-prompts.ts
+++ b/apps/ui/src/hooks/use-guided-prompts.ts
@@ -2,12 +2,12 @@
* Hook for fetching guided prompts from the backend API
*
* This hook provides the single source of truth for guided prompts,
- * fetched from the backend /api/ideation/prompts endpoint.
+ * with caching via React Query.
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types';
-import { getElectronAPI } from '@/lib/electron';
+import { useIdeationPrompts } from '@/hooks/queries';
interface UseGuidedPromptsReturn {
prompts: IdeationPrompt[];
@@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn {
}
export function useGuidedPrompts(): UseGuidedPromptsReturn {
- const [prompts, setPrompts] = useState([]);
- const [categories, setCategories] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
+ const { data, isLoading, error, refetch } = useIdeationPrompts();
- const fetchPrompts = useCallback(async () => {
- setIsLoading(true);
- setError(null);
-
- try {
- const api = getElectronAPI();
- const result = await api.ideation?.getPrompts();
-
- if (result?.success) {
- setPrompts(result.prompts || []);
- setCategories(result.categories || []);
- } else {
- setError(result?.error || 'Failed to fetch prompts');
- }
- } catch (err) {
- console.error('Failed to fetch guided prompts:', err);
- setError(err instanceof Error ? err.message : 'Failed to fetch prompts');
- } finally {
- setIsLoading(false);
- }
- }, []);
-
- useEffect(() => {
- fetchPrompts();
- }, [fetchPrompts]);
+ const prompts = data?.prompts ?? [];
+ const categories = data?.categories ?? [];
const getPromptsByCategory = useCallback(
(category: IdeaCategory): IdeationPrompt[] => {
@@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn {
[categories]
);
+ // Convert async refetch to match the expected interface
+ const handleRefetch = useCallback(async () => {
+ await refetch();
+ }, [refetch]);
+
+ // Convert error to string for backward compatibility
+ const errorMessage = useMemo(() => {
+ if (!error) return null;
+ return error instanceof Error ? error.message : String(error);
+ }, [error]);
+
return {
prompts,
categories,
isLoading,
- error,
- refetch: fetchPrompts,
+ error: errorMessage,
+ refetch: handleRefetch,
getPromptsByCategory,
getPromptById,
getCategoryById,
diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts
index da0ef594..e672d411 100644
--- a/apps/ui/src/hooks/use-project-settings-loader.ts
+++ b/apps/ui/src/hooks/use-project-settings-loader.ts
@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react';
import { useAppStore } from '@/store/app-store';
-import { getHttpApiClient } from '@/lib/http-api-client';
+import { useProjectSettings } from '@/hooks/queries';
/**
* Hook that loads project settings from the server when the current project changes.
* This ensures that settings like board backgrounds are properly restored when
* switching between projects or restarting the app.
+ *
+ * Uses React Query for data fetching with automatic caching.
*/
export function useProjectSettingsLoader() {
const currentProject = useAppStore((state) => state.currentProject);
@@ -23,97 +25,133 @@ export function useProjectSettingsLoader() {
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
+ const setCurrentProject = useAppStore((state) => state.setCurrentProject);
- const loadingRef = useRef(null);
- const currentProjectRef = useRef(null);
+ const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
+ // Fetch project settings with React Query
+ const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path);
+
+ // Apply settings when data changes
useEffect(() => {
- currentProjectRef.current = currentProject?.path ?? null;
-
- if (!currentProject?.path) {
+ if (!currentProject?.path || !settings) {
return;
}
- // Prevent loading the same project multiple times
- if (loadingRef.current === currentProject.path) {
+ // Prevent applying the same settings multiple times
+ if (
+ appliedProjectRef.current?.path === currentProject.path &&
+ appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt
+ ) {
return;
}
- loadingRef.current = currentProject.path;
- const requestedProjectPath = currentProject.path;
+ appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt };
+ const projectPath = currentProject.path;
- const loadProjectSettings = async () => {
- try {
- const httpClient = getHttpApiClient();
- const result = await httpClient.settings.getProject(requestedProjectPath);
+ const bg = settings.boardBackground;
- // Race condition protection: ignore stale results if project changed
- if (currentProjectRef.current !== requestedProjectPath) {
- return;
- }
+ // Apply boardBackground if present
+ if (bg?.imagePath) {
+ setBoardBackground(projectPath, bg.imagePath);
+ }
- if (result.success && result.settings) {
- const bg = result.settings.boardBackground;
+ // Settings map for cleaner iteration
+ const settingsMap = {
+ cardOpacity: setCardOpacity,
+ columnOpacity: setColumnOpacity,
+ columnBorderEnabled: setColumnBorderEnabled,
+ cardGlassmorphism: setCardGlassmorphism,
+ cardBorderEnabled: setCardBorderEnabled,
+ cardBorderOpacity: setCardBorderOpacity,
+ hideScrollbar: setHideScrollbar,
+ } as const;
- // Apply boardBackground if present
- if (bg?.imagePath) {
- setBoardBackground(requestedProjectPath, bg.imagePath);
- }
-
- // Settings map for cleaner iteration
- const settingsMap = {
- cardOpacity: setCardOpacity,
- columnOpacity: setColumnOpacity,
- columnBorderEnabled: setColumnBorderEnabled,
- cardGlassmorphism: setCardGlassmorphism,
- cardBorderEnabled: setCardBorderEnabled,
- cardBorderOpacity: setCardBorderOpacity,
- hideScrollbar: setHideScrollbar,
- } as const;
-
- // Apply all settings that are defined
- for (const [key, setter] of Object.entries(settingsMap)) {
- const value = bg?.[key as keyof typeof bg];
- if (value !== undefined) {
- (setter as (path: string, val: typeof value) => void)(requestedProjectPath, value);
- }
- }
-
- // Apply worktreePanelVisible if present
- if (result.settings.worktreePanelVisible !== undefined) {
- setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
- }
-
- // Apply showInitScriptIndicator if present
- if (result.settings.showInitScriptIndicator !== undefined) {
- setShowInitScriptIndicator(
- requestedProjectPath,
- result.settings.showInitScriptIndicator
- );
- }
-
- // Apply defaultDeleteBranch if present
- if (result.settings.defaultDeleteBranchWithWorktree !== undefined) {
- setDefaultDeleteBranch(
- requestedProjectPath,
- result.settings.defaultDeleteBranchWithWorktree
- );
- }
-
- // Apply autoDismissInitScriptIndicator if present
- if (result.settings.autoDismissInitScriptIndicator !== undefined) {
- setAutoDismissInitScriptIndicator(
- requestedProjectPath,
- result.settings.autoDismissInitScriptIndicator
- );
- }
- }
- } catch (error) {
- console.error('Failed to load project settings:', error);
- // Don't show error toast - just log it
+ // Apply all settings that are defined
+ for (const [key, setter] of Object.entries(settingsMap)) {
+ const value = bg?.[key as keyof typeof bg];
+ if (value !== undefined) {
+ (setter as (path: string, val: typeof value) => void)(projectPath, value);
}
- };
+ }
- loadProjectSettings();
- }, [currentProject?.path]);
+ // Apply worktreePanelVisible if present
+ if (settings.worktreePanelVisible !== undefined) {
+ setWorktreePanelVisible(projectPath, settings.worktreePanelVisible);
+ }
+
+ // Apply showInitScriptIndicator if present
+ if (settings.showInitScriptIndicator !== undefined) {
+ setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator);
+ }
+
+ // Apply defaultDeleteBranchWithWorktree if present
+ if (settings.defaultDeleteBranchWithWorktree !== undefined) {
+ setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree);
+ }
+
+ // Apply autoDismissInitScriptIndicator if present
+ if (settings.autoDismissInitScriptIndicator !== undefined) {
+ setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
+ }
+
+ // Apply activeClaudeApiProfileId and phaseModelOverrides if present
+ // These are stored directly on the project, so we need to update both
+ // currentProject AND the projects array to keep them in sync
+ // Type assertion needed because API returns Record
+ const settingsWithExtras = settings as Record;
+ const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as
+ | string
+ | null
+ | undefined;
+ const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as
+ | import('@automaker/types').PhaseModelConfig
+ | undefined;
+
+ // Check if we need to update the project
+ const storeState = useAppStore.getState();
+ const updatedProject = storeState.currentProject;
+ if (updatedProject && updatedProject.path === projectPath) {
+ const needsUpdate =
+ (activeClaudeApiProfileId !== undefined &&
+ updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
+ (phaseModelOverrides !== undefined &&
+ JSON.stringify(updatedProject.phaseModelOverrides) !==
+ JSON.stringify(phaseModelOverrides));
+
+ if (needsUpdate) {
+ const updatedProjectData = {
+ ...updatedProject,
+ ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
+ ...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
+ };
+
+ // Update currentProject
+ setCurrentProject(updatedProjectData);
+
+ // Also update the project in the projects array to keep them in sync
+ const updatedProjects = storeState.projects.map((p) =>
+ p.id === updatedProject.id ? updatedProjectData : p
+ );
+ useAppStore.setState({ projects: updatedProjects });
+ }
+ }
+ }, [
+ currentProject?.path,
+ settings,
+ dataUpdatedAt,
+ setBoardBackground,
+ setCardOpacity,
+ setColumnOpacity,
+ setColumnBorderEnabled,
+ setCardGlassmorphism,
+ setCardBorderEnabled,
+ setCardBorderOpacity,
+ setHideScrollbar,
+ setWorktreePanelVisible,
+ setShowInitScriptIndicator,
+ setDefaultDeleteBranch,
+ setAutoDismissInitScriptIndicator,
+ setCurrentProject,
+ ]);
}
diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts
new file mode 100644
index 00000000..eb0bfb4d
--- /dev/null
+++ b/apps/ui/src/hooks/use-query-invalidation.ts
@@ -0,0 +1,234 @@
+/**
+ * Query Invalidation Hooks
+ *
+ * These hooks connect WebSocket events to React Query cache invalidation,
+ * ensuring the UI stays in sync with server-side changes without manual refetching.
+ */
+
+import { useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { getElectronAPI } from '@/lib/electron';
+import { queryKeys } from '@/lib/query-keys';
+import type { AutoModeEvent, SpecRegenerationEvent } from '@/types/electron';
+import type { IssueValidationEvent } from '@automaker/types';
+
+/**
+ * Invalidate queries based on auto mode events
+ *
+ * This hook subscribes to auto mode events (feature start, complete, error, etc.)
+ * and invalidates relevant queries to keep the UI in sync.
+ *
+ * @param projectPath - Current project path
+ *
+ * @example
+ * ```tsx
+ * function BoardView() {
+ * const projectPath = useAppStore(s => s.currentProject?.path);
+ * useAutoModeQueryInvalidation(projectPath);
+ * // ...
+ * }
+ * ```
+ */
+export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const api = getElectronAPI();
+ const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
+ // Invalidate features when agent completes, errors, or receives plan approval
+ if (
+ event.type === 'auto_mode_feature_complete' ||
+ event.type === 'auto_mode_error' ||
+ event.type === 'plan_approval_required' ||
+ event.type === 'plan_approved' ||
+ event.type === 'plan_rejected' ||
+ event.type === 'pipeline_step_complete'
+ ) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+ }
+
+ // Invalidate running agents on any status change
+ if (
+ event.type === 'auto_mode_feature_start' ||
+ event.type === 'auto_mode_feature_complete' ||
+ event.type === 'auto_mode_error' ||
+ event.type === 'auto_mode_resuming_features'
+ ) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.runningAgents.all(),
+ });
+ }
+
+ // Invalidate specific feature when it starts or has phase changes
+ if (
+ (event.type === 'auto_mode_feature_start' ||
+ event.type === 'auto_mode_phase' ||
+ event.type === 'auto_mode_phase_complete' ||
+ event.type === 'pipeline_step_started') &&
+ 'featureId' in event
+ ) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.single(projectPath, event.featureId),
+ });
+ }
+
+ // Invalidate agent output during progress updates
+ if (event.type === 'auto_mode_progress' && 'featureId' in event) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.agentOutput(projectPath, event.featureId),
+ });
+ }
+
+ // Invalidate worktree queries when feature completes (may have created worktree)
+ if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.worktrees.all(projectPath),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.worktrees.single(projectPath, event.featureId),
+ });
+ }
+ });
+
+ return unsubscribe;
+ }, [projectPath, queryClient]);
+}
+
+/**
+ * Invalidate queries based on spec regeneration events
+ *
+ * @param projectPath - Current project path
+ */
+export function useSpecRegenerationQueryInvalidation(projectPath: string | undefined) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const api = getElectronAPI();
+ const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
+ // Only handle events for the current project
+ if (event.projectPath !== projectPath) return;
+
+ if (event.type === 'spec_regeneration_complete') {
+ // Invalidate features as new ones may have been generated
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.features.all(projectPath),
+ });
+
+ // Invalidate spec regeneration status
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.specRegeneration.status(projectPath),
+ });
+ }
+ });
+
+ return unsubscribe;
+ }, [projectPath, queryClient]);
+}
+
+/**
+ * Invalidate queries based on GitHub validation events
+ *
+ * @param projectPath - Current project path
+ */
+export function useGitHubValidationQueryInvalidation(projectPath: string | undefined) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const api = getElectronAPI();
+
+ // Check if GitHub API is available before subscribing
+ if (!api.github?.onValidationEvent) {
+ return;
+ }
+
+ const unsubscribe = api.github.onValidationEvent((event: IssueValidationEvent) => {
+ if (event.type === 'validation_complete' || event.type === 'validation_error') {
+ // Invalidate all validations for this project
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.github.validations(projectPath),
+ });
+
+ // Also invalidate specific issue validation if we have the issue number
+ if ('issueNumber' in event && event.issueNumber) {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.github.validation(projectPath, event.issueNumber),
+ });
+ }
+ }
+ });
+
+ return unsubscribe;
+ }, [projectPath, queryClient]);
+}
+
+/**
+ * Invalidate session queries based on agent stream events
+ *
+ * @param sessionId - Current session ID
+ */
+export function useSessionQueryInvalidation(sessionId: string | undefined) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!sessionId) return;
+
+ const api = getElectronAPI();
+ const unsubscribe = api.agent.onStream((event) => {
+ // Only handle events for the current session
+ if ('sessionId' in event && event.sessionId !== sessionId) return;
+
+ // Invalidate session history when a message is complete
+ if (event.type === 'complete' || event.type === 'message') {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.sessions.history(sessionId),
+ });
+ }
+
+ // Invalidate sessions list when any session changes
+ if (event.type === 'complete') {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.sessions.all(),
+ });
+ }
+ });
+
+ return unsubscribe;
+ }, [sessionId, queryClient]);
+}
+
+/**
+ * Combined hook that sets up all query invalidation subscriptions
+ *
+ * Use this hook at the app root or in a layout component to ensure
+ * all WebSocket events properly invalidate React Query caches.
+ *
+ * @param projectPath - Current project path
+ * @param sessionId - Current session ID (optional)
+ *
+ * @example
+ * ```tsx
+ * function AppLayout() {
+ * const projectPath = useAppStore(s => s.currentProject?.path);
+ * const sessionId = useAppStore(s => s.currentSessionId);
+ * useQueryInvalidation(projectPath, sessionId);
+ * // ...
+ * }
+ * ```
+ */
+export function useQueryInvalidation(
+ projectPath: string | undefined,
+ sessionId?: string | undefined
+) {
+ useAutoModeQueryInvalidation(projectPath);
+ useSpecRegenerationQueryInvalidation(projectPath);
+ useGitHubValidationQueryInvalidation(projectPath);
+ useSessionQueryInvalidation(sessionId);
+}
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts
index 07119b85..b77fba5b 100644
--- a/apps/ui/src/hooks/use-settings-migration.ts
+++ b/apps/ui/src/hooks/use-settings-migration.ts
@@ -30,8 +30,13 @@ import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import {
DEFAULT_OPENCODE_MODEL,
+ DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
+ getAllCursorModelIds,
+ migrateCursorModelIds,
+ migratePhaseModelEntry,
type GlobalSettings,
+ type CursorModelId,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -111,9 +116,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;
@@ -165,6 +195,7 @@ export function parseLocalStorageSettings(): Partial | null {
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
+ eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
projects: state.projects as GlobalSettings['projects'],
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
@@ -177,6 +208,13 @@ export function parseLocalStorageSettings(): Partial | null {
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
+ // Claude API Profiles (legacy)
+ claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
+ activeClaudeApiProfileId:
+ (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
+ // Claude Compatible Providers (new system)
+ claudeCompatibleProviders:
+ (state.claudeCompatibleProviders as GlobalSettings['claudeCompatibleProviders']) ?? [],
};
} catch (error) {
logger.error('Failed to parse localStorage settings:', error);
@@ -186,7 +224,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 +254,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,
@@ -282,6 +335,30 @@ export function mergeSettings(
merged.currentProjectId = localSettings.currentProjectId;
}
+ // Claude API Profiles - preserve from localStorage if server is empty
+ if (
+ (!serverSettings.claudeApiProfiles || serverSettings.claudeApiProfiles.length === 0) &&
+ localSettings.claudeApiProfiles &&
+ localSettings.claudeApiProfiles.length > 0
+ ) {
+ merged.claudeApiProfiles = localSettings.claudeApiProfiles;
+ }
+
+ // Active Claude API Profile ID - preserve from localStorage if server doesn't have one
+ if (!serverSettings.activeClaudeApiProfileId && localSettings.activeClaudeApiProfileId) {
+ merged.activeClaudeApiProfileId = localSettings.activeClaudeApiProfileId;
+ }
+
+ // Claude Compatible Providers - preserve from localStorage if server is empty
+ if (
+ (!serverSettings.claudeCompatibleProviders ||
+ serverSettings.claudeCompatibleProviders.length === 0) &&
+ localSettings.claudeCompatibleProviders &&
+ localSettings.claudeCompatibleProviders.length > 0
+ ) {
+ merged.claudeCompatibleProviders = localSettings.claudeCompatibleProviders;
+ }
+
return merged;
}
@@ -291,20 +368,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 +502,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 +603,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;
@@ -556,28 +668,56 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
setItem(THEME_STORAGE_KEY, storedTheme);
}
+ // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
+ const restoredAutoModeByWorktree: Record<
+ string,
+ {
+ isRunning: boolean;
+ runningTasks: string[];
+ branchName: string | null;
+ maxConcurrency: number;
+ }
+ > = {};
+ if ((settings as Record).autoModeByWorktree) {
+ const persistedSettings = (settings as Record).autoModeByWorktree as Record<
+ string,
+ { maxConcurrency?: number; branchName?: string | null }
+ >;
+ for (const [key, value] of Object.entries(persistedSettings)) {
+ restoredAutoModeByWorktree[key] = {
+ isRunning: false, // Always start with auto mode off
+ runningTasks: [], // No running tasks on startup
+ branchName: value.branchName ?? null,
+ maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ };
+ }
+ }
+
useAppStore.setState({
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
fontFamilySans: settings.fontFamilySans ?? null,
fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true,
chatHistoryOpen: settings.chatHistoryOpen ?? false,
- maxConcurrency: settings.maxConcurrency ?? 3,
+ maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: settings.defaultSkipTests ?? true,
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
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,
@@ -590,6 +730,10 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
},
mcpServers: settings.mcpServers ?? [],
promptCustomization: settings.promptCustomization ?? {},
+ eventHooks: settings.eventHooks ?? [],
+ claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
+ claudeApiProfiles: settings.claudeApiProfiles ?? [],
+ activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
projects,
currentProject,
trashedProjects: settings.trashedProjects ?? [],
@@ -624,6 +768,19 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
function buildSettingsUpdateFromStore(): Record {
const state = useAppStore.getState();
const setupState = useSetupStore.getState();
+
+ // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
+ const persistedAutoModeByWorktree: Record<
+ string,
+ { maxConcurrency: number; branchName: string | null }
+ > = {};
+ for (const [key, value] of Object.entries(state.autoModeByWorktree)) {
+ persistedAutoModeByWorktree[key] = {
+ maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ branchName: value.branchName,
+ };
+ }
+
return {
setupComplete: setupState.setupComplete,
isFirstRun: setupState.isFirstRun,
@@ -632,6 +789,7 @@ function buildSettingsUpdateFromStore(): Record {
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
maxConcurrency: state.maxConcurrency,
+ autoModeByWorktree: persistedAutoModeByWorktree,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
@@ -651,6 +809,10 @@ function buildSettingsUpdateFromStore(): Record {
keyboardShortcuts: state.keyboardShortcuts,
mcpServers: state.mcpServers,
promptCustomization: state.promptCustomization,
+ eventHooks: state.eventHooks,
+ claudeCompatibleProviders: state.claudeCompatibleProviders,
+ claudeApiProfiles: state.claudeApiProfiles,
+ activeClaudeApiProfileId: state.activeClaudeApiProfileId,
projects: state.projects,
trashedProjects: state.trashedProjects,
currentProjectId: state.currentProject?.id ?? null,
diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts
index ea865566..8ede5600 100644
--- a/apps/ui/src/hooks/use-settings-sync.ts
+++ b/apps/ui/src/hooks/use-settings-sync.ts
@@ -21,8 +21,15 @@ import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import {
DEFAULT_OPENCODE_MODEL,
+ DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
+ getAllCursorModelIds,
+ migrateCursorModelIds,
+ migrateOpencodeModelIds,
+ migratePhaseModelEntry,
type GlobalSettings,
+ type CursorModelId,
+ type OpencodeModelId,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -36,9 +43,11 @@ const SETTINGS_FIELDS_TO_SYNC = [
'fontFamilySans',
'fontFamilyMono',
'terminalFontFamily', // Maps to terminalState.fontFamily
+ 'openTerminalMode', // Maps to terminalState.openTerminalMode
'sidebarOpen',
'chatHistoryOpen',
'maxConcurrency',
+ 'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
'defaultSkipTests',
'enableDependencyBlocking',
'skipVerificationInAutoMode',
@@ -62,8 +71,11 @@ const SETTINGS_FIELDS_TO_SYNC = [
'keyboardShortcuts',
'mcpServers',
'defaultEditorCommand',
+ 'defaultTerminalId',
'promptCustomization',
'eventHooks',
+ 'claudeApiProfiles',
+ 'activeClaudeApiProfileId',
'projects',
'trashedProjects',
'currentProjectId', // ID of currently open project
@@ -81,7 +93,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],
@@ -93,11 +113,37 @@ function getSettingsFieldValue(
if (field === 'terminalFontFamily') {
return appState.terminalState.fontFamily;
}
+ if (field === 'openTerminalMode') {
+ return appState.terminalState.openTerminalMode;
+ }
+ if (field === 'autoModeByWorktree') {
+ // Only persist settings (maxConcurrency), not runtime state (isRunning, runningTasks)
+ const autoModeByWorktree = appState.autoModeByWorktree;
+ const persistedSettings: Record =
+ {};
+ for (const [key, value] of Object.entries(autoModeByWorktree)) {
+ persistedSettings[key] = {
+ maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ branchName: value.branchName,
+ };
+ }
+ return persistedSettings;
+ }
return appState[field as keyof typeof appState];
}
/**
* 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],
@@ -110,6 +156,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];
}
@@ -172,14 +221,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;
}
@@ -187,7 +240,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 = {};
@@ -204,17 +259,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);
}
@@ -340,9 +408,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;
@@ -449,17 +532,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);
@@ -471,31 +572,90 @@ 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);
}
+ // Restore autoModeByWorktree settings (only maxConcurrency is persisted, runtime state is reset)
+ const restoredAutoModeByWorktree: Record<
+ string,
+ {
+ isRunning: boolean;
+ runningTasks: string[];
+ branchName: string | null;
+ maxConcurrency: number;
+ }
+ > = {};
+ if (serverSettings.autoModeByWorktree) {
+ const persistedSettings = serverSettings.autoModeByWorktree as Record<
+ string,
+ { maxConcurrency?: number; branchName?: string | null }
+ >;
+ for (const [key, value] of Object.entries(persistedSettings)) {
+ restoredAutoModeByWorktree[key] = {
+ isRunning: false, // Always start with auto mode off
+ runningTasks: [], // No running tasks on startup
+ branchName: value.branchName ?? null,
+ maxConcurrency: value.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ };
+ }
+ }
+
useAppStore.setState({
theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
chatHistoryOpen: serverSettings.chatHistoryOpen,
maxConcurrency: serverSettings.maxConcurrency,
+ autoModeByWorktree: restoredAutoModeByWorktree,
defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
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,
@@ -509,7 +669,10 @@ 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,
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,
projectHistory: serverSettings.projectHistory,
@@ -519,11 +682,18 @@ export async function refreshSettingsFromServer(): Promise {
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '',
recentFolders: serverSettings.recentFolders ?? [],
- // Terminal font (nested in terminalState)
- ...(serverSettings.terminalFontFamily && {
+ // Event hooks
+ eventHooks: serverSettings.eventHooks ?? [],
+ // 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..4f311025 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -28,6 +28,7 @@ import type {
UpdateIdeaInput,
ConvertToFeatureOptions,
} from '@automaker/types';
+import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
// Re-export issue validation types for use in components
@@ -479,26 +480,34 @@ export interface FeaturesAPI {
featureId: string
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
generateTitle: (
- description: string
+ description: string,
+ projectPath?: string
) => Promise<{ success: boolean; title?: string; error?: string }>;
}
export interface AutoModeAPI {
start: (
projectPath: string,
+ branchName?: string | null,
maxConcurrency?: number
) => Promise<{ success: boolean; error?: string }>;
stop: (
- projectPath: string
+ projectPath: string,
+ branchName?: string | null
) => Promise<{ success: boolean; error?: string; runningFeatures?: number }>;
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
- status: (projectPath?: string) => Promise<{
+ status: (
+ projectPath?: string,
+ branchName?: string | null
+ ) => Promise<{
success: boolean;
isRunning?: boolean;
+ isAutoLoopRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
+ maxConcurrency?: number;
error?: string;
}>;
runFeature: (
@@ -704,7 +713,8 @@ export interface ElectronAPI {
originalText: string,
enhancementMode: string,
model?: string,
- thinkingLevel?: string
+ thinkingLevel?: string,
+ projectPath?: string
) => Promise<{
success: boolean;
enhancedText?: string;
@@ -1556,15 +1566,18 @@ function createMockWorktreeAPI(): WorktreeAPI {
projectPath: string,
branchName: string,
worktreePath: string,
+ targetBranch?: string,
options?: object
) => {
+ const target = targetBranch || 'main';
console.log('[Mock] Merging feature:', {
projectPath,
branchName,
worktreePath,
+ targetBranch: target,
options,
});
- return { success: true, mergedBranch: branchName };
+ return { success: true, mergedBranch: branchName, targetBranch: target };
},
getInfo: async (projectPath: string, featureId: string) => {
@@ -1674,14 +1687,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
- push: async (worktreePath: string, force?: boolean) => {
- console.log('[Mock] Pushing worktree:', { worktreePath, force });
+ push: async (worktreePath: string, force?: boolean, remote?: string) => {
+ const targetRemote = remote || 'origin';
+ console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
return {
success: true,
result: {
branch: 'feature-branch',
pushed: true,
- message: 'Successfully pushed to origin/feature-branch',
+ message: `Successfully pushed to ${targetRemote}/feature-branch`,
},
};
},
@@ -1767,6 +1781,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
],
aheadCount: 2,
behindCount: 0,
+ hasRemoteBranch: true,
},
};
},
@@ -1783,6 +1798,26 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
+ listRemotes: async (worktreePath: string) => {
+ console.log('[Mock] Listing remotes for:', worktreePath);
+ return {
+ success: true,
+ result: {
+ remotes: [
+ {
+ name: 'origin',
+ url: 'git@github.com:example/repo.git',
+ branches: [
+ { name: 'main', fullRef: 'origin/main' },
+ { name: 'develop', fullRef: 'origin/develop' },
+ { name: 'feature/example', fullRef: 'origin/feature/example' },
+ ],
+ },
+ ],
+ },
+ };
+ },
+
openInEditor: async (worktreePath: string, editorCommand?: string) => {
const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
const ANTIGRAVITY_LEGACY_COMMAND = 'agy';
@@ -1852,6 +1887,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 {
@@ -1964,6 +2049,20 @@ function createMockWorktreeAPI(): WorktreeAPI {
console.log('[Mock] Unsubscribing from init script events');
};
},
+
+ discardChanges: async (worktreePath: string) => {
+ console.log('[Mock] Discarding changes:', { worktreePath });
+ return {
+ success: true,
+ result: {
+ discarded: true,
+ filesDiscarded: 0,
+ filesRemaining: 0,
+ branch: 'main',
+ message: 'Mock: Changes discarded successfully',
+ },
+ };
+ },
};
}
@@ -2008,7 +2107,9 @@ function createMockAutoModeAPI(): AutoModeAPI {
}
mockAutoModeRunning = true;
- console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`);
+ console.log(
+ `[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || DEFAULT_MAX_CONCURRENCY}`
+ );
const featureId = 'auto-mode-0';
mockRunningFeatures.add(featureId);
@@ -3121,7 +3222,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
return { success: true, content: content || null };
},
- generateTitle: async (description: string) => {
+ generateTitle: async (description: string, _projectPath?: string) => {
console.log('[Mock] Generating title for:', description.substring(0, 50));
// Mock title generation - just take first few words
const words = description.split(/\s+/).slice(0, 6).join(' ');
@@ -3226,7 +3327,7 @@ function createMockGitHubAPI(): GitHubAPI {
estimatedComplexity: 'moderate' as const,
},
projectPath,
- model: model || 'sonnet',
+ model: model || 'claude-sonnet',
})
);
}, 2000);
@@ -3297,6 +3398,20 @@ export interface Project {
isFavorite?: boolean; // Pin project to top of dashboard
icon?: string; // Lucide icon name for project identification
customIconPath?: string; // Path to custom uploaded icon image in .automaker/images/
+ /**
+ * Override the active Claude API profile for this project.
+ * - undefined: Use global setting (activeClaudeApiProfileId)
+ * - null: Explicitly use Direct Anthropic API (no profile)
+ * - string: Use specific profile by ID
+ * @deprecated Use phaseModelOverrides instead for per-phase model selection
+ */
+ activeClaudeApiProfileId?: string | null;
+ /**
+ * Per-phase model overrides for this project.
+ * Keys are phase names (e.g., 'enhancementModel'), values are PhaseModelEntry.
+ * If a phase is not present, the global setting is used.
+ */
+ phaseModelOverrides?: Partial;
}
export interface TrashedProject extends Project {
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index cd0e6739..dbfddc4c 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
+ }
};
/**
@@ -1619,8 +1657,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post('/api/features/agent-output', { projectPath, featureId }),
- generateTitle: (description: string) =>
- this.post('/api/features/generate-title', { description }),
+ generateTitle: (description: string, projectPath?: string) =>
+ this.post('/api/features/generate-title', { description, projectPath }),
bulkUpdate: (projectPath: string, featureIds: string[], updates: Partial) =>
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
bulkDelete: (projectPath: string, featureIds: string[]) =>
@@ -1629,11 +1667,13 @@ export class HttpApiClient implements ElectronAPI {
// Auto Mode API
autoMode: AutoModeAPI = {
- start: (projectPath: string, maxConcurrency?: number) =>
- this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
- stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
+ start: (projectPath: string, branchName?: string | null, maxConcurrency?: number) =>
+ this.post('/api/auto-mode/start', { projectPath, branchName, maxConcurrency }),
+ stop: (projectPath: string, branchName?: string | null) =>
+ this.post('/api/auto-mode/stop', { projectPath, branchName }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
- status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
+ status: (projectPath?: string, branchName?: string | null) =>
+ this.post('/api/auto-mode/status', { projectPath, branchName }),
runFeature: (
projectPath: string,
featureId: string,
@@ -1705,13 +1745,15 @@ export class HttpApiClient implements ElectronAPI {
originalText: string,
enhancementMode: string,
model?: string,
- thinkingLevel?: string
+ thinkingLevel?: string,
+ projectPath?: string
): Promise =>
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
thinkingLevel,
+ projectPath,
}),
};
@@ -1721,8 +1763,16 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string,
branchName: string,
worktreePath: string,
+ targetBranch?: string,
options?: object
- ) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }),
+ ) =>
+ this.post('/api/worktree/merge', {
+ projectPath,
+ branchName,
+ worktreePath,
+ targetBranch,
+ options,
+ }),
getInfo: (projectPath: string, featureId: string) =>
this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
@@ -1746,8 +1796,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/commit', { worktreePath, message }),
generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }),
- push: (worktreePath: string, force?: boolean) =>
- this.post('/api/worktree/push', { worktreePath, force }),
+ push: (worktreePath: string, force?: boolean, remote?: string) =>
+ this.post('/api/worktree/push', { worktreePath, force, remote }),
createPR: (worktreePath: string, options?: any) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
@@ -1765,11 +1815,18 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
+ listRemotes: (worktreePath: string) =>
+ this.post('/api/worktree/list-remotes', { worktreePath }),
openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
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 }),
@@ -1804,6 +1861,8 @@ export class HttpApiClient implements ElectronAPI {
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
+ discardChanges: (worktreePath: string) =>
+ this.post('/api/worktree/discard-changes', { worktreePath }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
diff --git a/apps/ui/src/lib/query-client.ts b/apps/ui/src/lib/query-client.ts
new file mode 100644
index 00000000..82344f2a
--- /dev/null
+++ b/apps/ui/src/lib/query-client.ts
@@ -0,0 +1,138 @@
+/**
+ * React Query Client Configuration
+ *
+ * Central configuration for TanStack React Query.
+ * Provides default options for queries and mutations including
+ * caching, retries, and error handling.
+ */
+
+import { QueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { createLogger } from '@automaker/utils/logger';
+import { isConnectionError, handleServerOffline } from './http-api-client';
+
+const logger = createLogger('QueryClient');
+
+/**
+ * Default stale times for different data types
+ */
+export const STALE_TIMES = {
+ /** Features change frequently during auto-mode */
+ FEATURES: 60 * 1000, // 1 minute
+ /** GitHub data is relatively stable */
+ GITHUB: 2 * 60 * 1000, // 2 minutes
+ /** Running agents state changes very frequently */
+ RUNNING_AGENTS: 5 * 1000, // 5 seconds
+ /** Agent output changes during streaming */
+ AGENT_OUTPUT: 5 * 1000, // 5 seconds
+ /** Usage data with polling */
+ USAGE: 30 * 1000, // 30 seconds
+ /** Models rarely change */
+ MODELS: 5 * 60 * 1000, // 5 minutes
+ /** CLI status rarely changes */
+ CLI_STATUS: 5 * 60 * 1000, // 5 minutes
+ /** Settings are relatively stable */
+ SETTINGS: 2 * 60 * 1000, // 2 minutes
+ /** Worktrees change during feature development */
+ WORKTREES: 30 * 1000, // 30 seconds
+ /** Sessions rarely change */
+ SESSIONS: 2 * 60 * 1000, // 2 minutes
+ /** Default for unspecified queries */
+ DEFAULT: 30 * 1000, // 30 seconds
+} as const;
+
+/**
+ * Default garbage collection times (gcTime, formerly cacheTime)
+ */
+export const GC_TIMES = {
+ /** Default garbage collection time */
+ DEFAULT: 5 * 60 * 1000, // 5 minutes
+ /** Extended for expensive queries */
+ EXTENDED: 10 * 60 * 1000, // 10 minutes
+} as const;
+
+/**
+ * Global error handler for queries
+ */
+const handleQueryError = (error: Error) => {
+ logger.error('Query error:', error);
+
+ // Check for connection errors (server offline)
+ if (isConnectionError(error)) {
+ handleServerOffline();
+ return;
+ }
+
+ // Don't toast for auth errors - those are handled by http-api-client
+ if (error.message === 'Unauthorized') {
+ return;
+ }
+};
+
+/**
+ * Global error handler for mutations
+ */
+const handleMutationError = (error: Error) => {
+ logger.error('Mutation error:', error);
+
+ // Check for connection errors
+ if (isConnectionError(error)) {
+ handleServerOffline();
+ return;
+ }
+
+ // Don't toast for auth errors
+ if (error.message === 'Unauthorized') {
+ return;
+ }
+
+ // Show error toast for other errors
+ toast.error('Operation failed', {
+ description: error.message || 'An unexpected error occurred',
+ });
+};
+
+/**
+ * Create and configure the QueryClient singleton
+ */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: STALE_TIMES.DEFAULT,
+ gcTime: GC_TIMES.DEFAULT,
+ retry: (failureCount, error) => {
+ // Don't retry on auth errors
+ if (error instanceof Error && error.message === 'Unauthorized') {
+ return false;
+ }
+ // Don't retry on connection errors (server offline)
+ if (isConnectionError(error)) {
+ return false;
+ }
+ // Retry up to 2 times for other errors
+ return failureCount < 2;
+ },
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ // Don't refetch on mount if data is fresh
+ refetchOnMount: true,
+ },
+ mutations: {
+ onError: handleMutationError,
+ retry: false, // Don't auto-retry mutations
+ },
+ },
+});
+
+/**
+ * Set up global query error handling
+ * This catches errors that aren't handled by individual queries
+ */
+queryClient.getQueryCache().subscribe((event) => {
+ if (event.type === 'updated' && event.query.state.status === 'error') {
+ const error = event.query.state.error;
+ if (error instanceof Error) {
+ handleQueryError(error);
+ }
+ }
+});
diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts
new file mode 100644
index 00000000..feb69c65
--- /dev/null
+++ b/apps/ui/src/lib/query-keys.ts
@@ -0,0 +1,282 @@
+/**
+ * Query Keys Factory
+ *
+ * Centralized query key definitions for React Query.
+ * Following the factory pattern for type-safe, consistent query keys.
+ *
+ * @see https://tkdodo.eu/blog/effective-react-query-keys
+ */
+
+/**
+ * Query keys for all API endpoints
+ *
+ * Structure follows the pattern:
+ * - ['entity'] for listing/global
+ * - ['entity', id] for single item
+ * - ['entity', id, 'sub-resource'] for nested resources
+ */
+export const queryKeys = {
+ // ============================================
+ // Features
+ // ============================================
+ features: {
+ /** All features for a project */
+ all: (projectPath: string) => ['features', projectPath] as const,
+ /** Single feature */
+ single: (projectPath: string, featureId: string) =>
+ ['features', projectPath, featureId] as const,
+ /** Agent output for a feature */
+ agentOutput: (projectPath: string, featureId: string) =>
+ ['features', projectPath, featureId, 'output'] as const,
+ },
+
+ // ============================================
+ // Worktrees
+ // ============================================
+ worktrees: {
+ /** All worktrees for a project */
+ all: (projectPath: string) => ['worktrees', projectPath] as const,
+ /** Single worktree info */
+ single: (projectPath: string, featureId: string) =>
+ ['worktrees', projectPath, featureId] as const,
+ /** Branches for a worktree */
+ branches: (worktreePath: string, includeRemote = false) =>
+ ['worktrees', 'branches', worktreePath, { includeRemote }] as const,
+ /** Worktree status */
+ status: (projectPath: string, featureId: string) =>
+ ['worktrees', projectPath, featureId, 'status'] as const,
+ /** Worktree diffs */
+ diffs: (projectPath: string, featureId: string) =>
+ ['worktrees', projectPath, featureId, 'diffs'] as const,
+ /** Init script for a project */
+ initScript: (projectPath: string) => ['worktrees', projectPath, 'init-script'] as const,
+ /** Available editors */
+ editors: () => ['worktrees', 'editors'] as const,
+ },
+
+ // ============================================
+ // GitHub
+ // ============================================
+ github: {
+ /** GitHub issues for a project */
+ issues: (projectPath: string) => ['github', 'issues', projectPath] as const,
+ /** GitHub PRs for a project */
+ prs: (projectPath: string) => ['github', 'prs', projectPath] as const,
+ /** GitHub validations for a project */
+ validations: (projectPath: string) => ['github', 'validations', projectPath] as const,
+ /** Single validation */
+ validation: (projectPath: string, issueNumber: number) =>
+ ['github', 'validations', projectPath, issueNumber] as const,
+ /** Issue comments */
+ issueComments: (projectPath: string, issueNumber: number) =>
+ ['github', 'issues', projectPath, issueNumber, 'comments'] as const,
+ /** Remote info */
+ remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
+ },
+
+ // ============================================
+ // Settings
+ // ============================================
+ settings: {
+ /** Global settings */
+ global: () => ['settings', 'global'] as const,
+ /** Project-specific settings */
+ project: (projectPath: string) => ['settings', 'project', projectPath] as const,
+ /** Settings status */
+ status: () => ['settings', 'status'] as const,
+ /** Credentials (API keys) */
+ credentials: () => ['settings', 'credentials'] as const,
+ /** Discovered agents */
+ agents: (projectPath: string, sources?: Array<'user' | 'project'>) =>
+ ['settings', 'agents', projectPath, sources ?? []] as const,
+ },
+
+ // ============================================
+ // Usage & Billing
+ // ============================================
+ usage: {
+ /** Claude API usage */
+ claude: () => ['usage', 'claude'] as const,
+ /** Codex API usage */
+ codex: () => ['usage', 'codex'] as const,
+ },
+
+ // ============================================
+ // Models
+ // ============================================
+ models: {
+ /** Available models */
+ available: () => ['models', 'available'] as const,
+ /** Codex models */
+ codex: () => ['models', 'codex'] as const,
+ /** OpenCode models */
+ opencode: () => ['models', 'opencode'] as const,
+ /** OpenCode providers */
+ opencodeProviders: () => ['models', 'opencode', 'providers'] as const,
+ /** Provider status */
+ providers: () => ['models', 'providers'] as const,
+ },
+
+ // ============================================
+ // Sessions
+ // ============================================
+ sessions: {
+ /** All sessions */
+ all: (includeArchived?: boolean) => ['sessions', { includeArchived }] as const,
+ /** Session history */
+ history: (sessionId: string) => ['sessions', sessionId, 'history'] as const,
+ /** Session queue */
+ queue: (sessionId: string) => ['sessions', sessionId, 'queue'] as const,
+ },
+
+ // ============================================
+ // Running Agents
+ // ============================================
+ runningAgents: {
+ /** All running agents */
+ all: () => ['runningAgents'] as const,
+ },
+
+ // ============================================
+ // Auto Mode
+ // ============================================
+ autoMode: {
+ /** Auto mode status */
+ status: (projectPath?: string) => ['autoMode', 'status', projectPath] as const,
+ /** Context exists check */
+ contextExists: (projectPath: string, featureId: string) =>
+ ['autoMode', projectPath, featureId, 'context'] as const,
+ },
+
+ // ============================================
+ // Ideation
+ // ============================================
+ ideation: {
+ /** Ideation prompts */
+ prompts: () => ['ideation', 'prompts'] as const,
+ /** Ideas for a project */
+ ideas: (projectPath: string) => ['ideation', 'ideas', projectPath] as const,
+ /** Single idea */
+ idea: (projectPath: string, ideaId: string) =>
+ ['ideation', 'ideas', projectPath, ideaId] as const,
+ /** Session */
+ session: (projectPath: string, sessionId: string) =>
+ ['ideation', 'session', projectPath, sessionId] as const,
+ },
+
+ // ============================================
+ // CLI Status
+ // ============================================
+ cli: {
+ /** Claude CLI status */
+ claude: () => ['cli', 'claude'] as const,
+ /** Cursor CLI status */
+ cursor: () => ['cli', 'cursor'] as const,
+ /** Codex CLI status */
+ codex: () => ['cli', 'codex'] as const,
+ /** OpenCode CLI status */
+ opencode: () => ['cli', 'opencode'] as const,
+ /** GitHub CLI status */
+ github: () => ['cli', 'github'] as const,
+ /** API keys status */
+ apiKeys: () => ['cli', 'apiKeys'] as const,
+ /** Platform info */
+ platform: () => ['cli', 'platform'] as const,
+ },
+
+ // ============================================
+ // Cursor Permissions
+ // ============================================
+ cursorPermissions: {
+ /** Cursor permissions for a project */
+ permissions: (projectPath?: string) => ['cursorPermissions', projectPath] as const,
+ },
+
+ // ============================================
+ // Workspace
+ // ============================================
+ workspace: {
+ /** Workspace config */
+ config: () => ['workspace', 'config'] as const,
+ /** Workspace directories */
+ directories: () => ['workspace', 'directories'] as const,
+ },
+
+ // ============================================
+ // MCP (Model Context Protocol)
+ // ============================================
+ mcp: {
+ /** MCP server tools */
+ tools: (serverId: string) => ['mcp', 'tools', serverId] as const,
+ },
+
+ // ============================================
+ // Pipeline
+ // ============================================
+ pipeline: {
+ /** Pipeline config for a project */
+ config: (projectPath: string) => ['pipeline', projectPath] as const,
+ },
+
+ // ============================================
+ // Suggestions
+ // ============================================
+ suggestions: {
+ /** Suggestions status */
+ status: () => ['suggestions', 'status'] as const,
+ },
+
+ // ============================================
+ // Spec Regeneration
+ // ============================================
+ specRegeneration: {
+ /** Spec regeneration status */
+ status: (projectPath?: string) => ['specRegeneration', 'status', projectPath] as const,
+ },
+
+ // ============================================
+ // Spec
+ // ============================================
+ spec: {
+ /** Spec file content */
+ file: (projectPath: string) => ['spec', 'file', projectPath] as const,
+ },
+
+ // ============================================
+ // Context
+ // ============================================
+ context: {
+ /** File description */
+ file: (filePath: string) => ['context', 'file', filePath] as const,
+ /** Image description */
+ image: (imagePath: string) => ['context', 'image', imagePath] as const,
+ },
+
+ // ============================================
+ // File System
+ // ============================================
+ fs: {
+ /** Directory listing */
+ readdir: (dirPath: string) => ['fs', 'readdir', dirPath] as const,
+ /** File existence */
+ exists: (filePath: string) => ['fs', 'exists', filePath] as const,
+ /** File stats */
+ stat: (filePath: string) => ['fs', 'stat', filePath] as const,
+ },
+
+ // ============================================
+ // Git
+ // ============================================
+ git: {
+ /** Git diffs for a project */
+ diffs: (projectPath: string) => ['git', 'diffs', projectPath] as const,
+ /** File diff */
+ fileDiff: (projectPath: string, filePath: string) =>
+ ['git', 'diffs', projectPath, filePath] as const,
+ },
+} as const;
+
+/**
+ * Type helper to extract query key types
+ */
+export type QueryKeys = typeof queryKeys;
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/__root.tsx b/apps/ui/src/routes/__root.tsx
index e1a115d5..4a56ca2b 100644
--- a/apps/ui/src/routes/__root.tsx
+++ b/apps/ui/src/routes/__root.tsx
@@ -1,5 +1,7 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
+import { QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
import { ProjectSwitcher } from '@/components/layout/project-switcher';
@@ -27,6 +29,7 @@ import {
signalMigrationComplete,
performSettingsMigration,
} from '@/hooks/use-settings-migration';
+import { queryClient } from '@/lib/query-client';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
@@ -37,6 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query';
import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout');
+const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV;
const SERVER_READY_MAX_ATTEMPTS = 8;
const SERVER_READY_BACKOFF_BASE_MS = 250;
const SERVER_READY_MAX_DELAY_MS = 1500;
@@ -891,10 +895,18 @@ function RootLayoutContent() {
}
function RootLayout() {
+ // Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
+ const isCompact = useIsCompact();
+
return (
-
-
-
+
+
+
+
+ {SHOW_QUERY_DEVTOOLS && !isCompact ? (
+
+ ) : null}
+
);
}
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 a23c17c4..63dd7960 100644
--- a/apps/ui/src/store/app-store.ts
+++ b/apps/ui/src/store/app-store.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
+import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
import { setItem, getItem } from '@/lib/storage';
import {
@@ -31,6 +32,8 @@ import type {
ModelDefinition,
ServerLogLevel,
EventHook,
+ ClaudeApiProfile,
+ ClaudeCompatibleProvider,
} from '@automaker/types';
import {
getAllCursorModelIds,
@@ -38,6 +41,7 @@ import {
getAllOpencodeModelIds,
DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL,
+ DEFAULT_MAX_CONCURRENCY,
} from '@automaker/types';
const logger = createLogger('AppStore');
@@ -500,7 +504,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
@@ -531,12 +535,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
@@ -574,6 +579,7 @@ export interface PersistedTerminalSettings {
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
+ openTerminalMode: 'newTab' | 'split';
}
/** State for worktree init script execution */
@@ -624,16 +630,18 @@ export interface AppState {
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
- // Auto Mode (per-project state, keyed by project ID)
- autoModeByProject: Record<
+ // Auto Mode (per-worktree state, keyed by "${projectId}::${branchName ?? '__main__'}")
+ autoModeByWorktree: Record<
string,
{
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
+ branchName: string | null; // null = main worktree
+ maxConcurrency?: number; // Maximum concurrent features for this worktree (defaults to 3)
}
>;
autoModeActivityLog: AutoModeActivity[];
- maxConcurrency: number; // Maximum number of concurrent agent tasks
+ maxConcurrency: number; // Legacy: Maximum number of concurrent agent tasks (deprecated, use per-worktree maxConcurrency)
// Kanban Card Display Settings
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
@@ -728,6 +736,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
@@ -742,6 +753,13 @@ export interface AppState {
// Event Hooks
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
+ // Claude-Compatible Providers (new system)
+ claudeCompatibleProviders: ClaudeCompatibleProvider[]; // Providers that expose models to dropdowns
+
+ // Claude API Profiles (deprecated - kept for backward compatibility)
+ claudeApiProfiles: ClaudeApiProfile[]; // Claude-compatible API endpoint profiles
+ activeClaudeApiProfileId: string | null; // Active profile ID (null = use direct Anthropic API)
+
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -1025,6 +1043,18 @@ export interface AppActions {
getEffectiveFontSans: () => string | null; // Get effective UI font (project override -> global -> null for default)
getEffectiveFontMono: () => string | null; // Get effective code font (project override -> global -> null for default)
+ // Claude API Profile actions (per-project override)
+ /** @deprecated Use setProjectPhaseModelOverride instead */
+ setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => void; // Set per-project Claude API profile (undefined = use global, null = direct API, string = specific profile)
+
+ // Project Phase Model Overrides
+ setProjectPhaseModelOverride: (
+ projectId: string,
+ phase: import('@automaker/types').PhaseModelKey,
+ entry: import('@automaker/types').PhaseModelEntry | null // null = use global
+ ) => void;
+ clearAllProjectPhaseModelOverrides: (projectId: string) => void;
+
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial) => void;
@@ -1052,18 +1082,37 @@ export interface AppActions {
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
- // Auto Mode actions (per-project)
- setAutoModeRunning: (projectId: string, running: boolean) => void;
- addRunningTask: (projectId: string, taskId: string) => void;
- removeRunningTask: (projectId: string, taskId: string) => void;
- clearRunningTasks: (projectId: string) => void;
- getAutoModeState: (projectId: string) => {
+ // Auto Mode actions (per-worktree)
+ setAutoModeRunning: (
+ projectId: string,
+ branchName: string | null,
+ running: boolean,
+ maxConcurrency?: number,
+ runningTasks?: string[]
+ ) => void;
+ addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
+ removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
+ clearRunningTasks: (projectId: string, branchName: string | null) => void;
+ getAutoModeState: (
+ projectId: string,
+ branchName: string | null
+ ) => {
isRunning: boolean;
runningTasks: string[];
+ branchName: string | null;
+ maxConcurrency?: number;
};
+ /** Helper to generate worktree key from projectId and branchName */
+ getWorktreeKey: (projectId: string, branchName: string | null) => string;
addAutoModeActivity: (activity: Omit) => void;
clearAutoModeActivity: () => void;
- setMaxConcurrency: (max: number) => void;
+ setMaxConcurrency: (max: number) => void; // Legacy: kept for backward compatibility
+ getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => number;
+ setMaxConcurrencyForWorktree: (
+ projectId: string,
+ branchName: string | null,
+ maxConcurrency: number
+ ) => void;
// Kanban Card Settings actions
setBoardViewMode: (mode: BoardViewMode) => void;
@@ -1166,12 +1215,32 @@ 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;
// Event Hook actions
setEventHooks: (hooks: EventHook[]) => void;
+ // Claude-Compatible Provider actions (new system)
+ addClaudeCompatibleProvider: (provider: ClaudeCompatibleProvider) => Promise;
+ updateClaudeCompatibleProvider: (
+ id: string,
+ updates: Partial
+ ) => Promise;
+ deleteClaudeCompatibleProvider: (id: string) => Promise;
+ setClaudeCompatibleProviders: (providers: ClaudeCompatibleProvider[]) => Promise;
+ toggleClaudeCompatibleProviderEnabled: (id: string) => Promise;
+
+ // Claude API Profile actions (deprecated - kept for backward compatibility)
+ addClaudeApiProfile: (profile: ClaudeApiProfile) => Promise;
+ updateClaudeApiProfile: (id: string, updates: Partial) => Promise;
+ deleteClaudeApiProfile: (id: string) => Promise;
+ setActiveClaudeApiProfile: (id: string | null) => Promise;
+ setClaudeApiProfiles: (profiles: ClaudeApiProfile[]) => Promise;
+
// MCP Server actions
addMCPServer: (server: Omit) => void;
updateMCPServer: (id: string, updates: Partial) => void;
@@ -1215,7 +1284,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;
@@ -1229,6 +1299,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;
@@ -1238,7 +1309,8 @@ export interface AppActions {
addTerminalToTab: (
sessionId: string,
tabId: string,
- direction?: 'horizontal' | 'vertical'
+ direction?: 'horizontal' | 'vertical',
+ branchName?: string
) => void;
setTerminalTabLayout: (
tabId: string,
@@ -1376,9 +1448,9 @@ const initialState: AppState = {
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
- autoModeByProject: {},
+ autoModeByWorktree: {},
autoModeActivityLog: [],
- maxConcurrency: 3, // Default to 3 concurrent agents
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY, // Default concurrent agents
boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
@@ -1393,12 +1465,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)
@@ -1420,12 +1492,16 @@ 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
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, // Load from both sources by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
eventHooks: [], // No event hooks configured by default
+ claudeCompatibleProviders: [], // Claude-compatible providers that expose models
+ claudeApiProfiles: [], // No Claude API profiles configured by default (deprecated)
+ activeClaudeApiProfileId: null, // Use direct Anthropic API by default (deprecated)
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
@@ -1445,6 +1521,7 @@ const initialState: AppState = {
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
+ openTerminalMode: 'newTab',
},
terminalLayoutByProject: {},
specCreatingForProject: null,
@@ -1504,7 +1581,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);
@@ -1517,6 +1603,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],
@@ -1613,16 +1704,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({
@@ -1907,6 +2000,139 @@ export const useAppStore = create()((set, get) => ({
return getEffectiveFont(currentProject?.fontFamilyMono, fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
+ // Claude API Profile actions (per-project override)
+ setProjectClaudeApiProfile: (projectId, profileId) => {
+ // Find the project to get its path for server sync
+ const project = get().projects.find((p) => p.id === projectId);
+ if (!project) {
+ console.error('Cannot set Claude API profile: project not found');
+ return;
+ }
+
+ // Update the project's activeClaudeApiProfileId property
+ // undefined means "use global", null means "explicit direct API", string means specific profile
+ const projects = get().projects.map((p) =>
+ p.id === projectId ? { ...p, activeClaudeApiProfileId: profileId } : p
+ );
+ set({ projects });
+
+ // Also update currentProject if it's the same project
+ const currentProject = get().currentProject;
+ if (currentProject?.id === projectId) {
+ set({
+ currentProject: {
+ ...currentProject,
+ activeClaudeApiProfileId: profileId,
+ },
+ });
+ }
+
+ // Persist to server
+ // Note: undefined means "use global" but JSON doesn't serialize undefined,
+ // so we use a special marker string "__USE_GLOBAL__" to signal deletion
+ const httpClient = getHttpApiClient();
+ const serverValue = profileId === undefined ? '__USE_GLOBAL__' : profileId;
+ httpClient.settings
+ .updateProject(project.path, {
+ activeClaudeApiProfileId: serverValue,
+ })
+ .catch((error) => {
+ console.error('Failed to persist activeClaudeApiProfileId:', error);
+ });
+ },
+
+ // Project Phase Model Override actions
+ setProjectPhaseModelOverride: (projectId, phase, entry) => {
+ // Find the project to get its path for server sync
+ const project = get().projects.find((p) => p.id === projectId);
+ if (!project) {
+ console.error('Cannot set phase model override: project not found');
+ return;
+ }
+
+ // Get current overrides or start fresh
+ const currentOverrides = project.phaseModelOverrides || {};
+
+ // Build new overrides
+ let newOverrides: typeof currentOverrides;
+ if (entry === null) {
+ // Remove the override (use global)
+ const { [phase]: _, ...rest } = currentOverrides;
+ newOverrides = rest;
+ } else {
+ // Set the override
+ newOverrides = { ...currentOverrides, [phase]: entry };
+ }
+
+ // Update the project's phaseModelOverrides
+ const projects = get().projects.map((p) =>
+ p.id === projectId
+ ? {
+ ...p,
+ phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
+ }
+ : p
+ );
+ set({ projects });
+
+ // Also update currentProject if it's the same project
+ const currentProject = get().currentProject;
+ if (currentProject?.id === projectId) {
+ set({
+ currentProject: {
+ ...currentProject,
+ phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
+ },
+ });
+ }
+
+ // Persist to server
+ const httpClient = getHttpApiClient();
+ httpClient.settings
+ .updateProject(project.path, {
+ phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : '__CLEAR__',
+ })
+ .catch((error) => {
+ console.error('Failed to persist phaseModelOverrides:', error);
+ });
+ },
+
+ clearAllProjectPhaseModelOverrides: (projectId) => {
+ // Find the project to get its path for server sync
+ const project = get().projects.find((p) => p.id === projectId);
+ if (!project) {
+ console.error('Cannot clear phase model overrides: project not found');
+ return;
+ }
+
+ // Clear overrides from project
+ const projects = get().projects.map((p) =>
+ p.id === projectId ? { ...p, phaseModelOverrides: undefined } : p
+ );
+ set({ projects });
+
+ // Also update currentProject if it's the same project
+ const currentProject = get().currentProject;
+ if (currentProject?.id === projectId) {
+ set({
+ currentProject: {
+ ...currentProject,
+ phaseModelOverrides: undefined,
+ },
+ });
+ }
+
+ // Persist to server
+ const httpClient = getHttpApiClient();
+ httpClient.settings
+ .updateProject(project.path, {
+ phaseModelOverrides: '__CLEAR__',
+ })
+ .catch((error) => {
+ console.error('Failed to clear phaseModelOverrides:', error);
+ });
+ },
+
// Feature actions
setFeatures: (features) => set({ features }),
@@ -2044,74 +2270,135 @@ export const useAppStore = create()((set, get) => ({
toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }),
- // Auto Mode actions (per-project)
- setAutoModeRunning: (projectId, running) => {
- const current = get().autoModeByProject;
- const projectState = current[projectId] || {
+ // Auto Mode actions (per-worktree)
+ getWorktreeKey: (projectId, branchName) => {
+ // Normalize 'main' to null so it matches the main worktree key
+ // The backend sometimes sends 'main' while the UI uses null for the main worktree
+ const normalizedBranch = branchName === 'main' ? null : branchName;
+ return `${projectId}::${normalizedBranch ?? '__main__'}`;
+ },
+
+ setAutoModeRunning: (
+ projectId: string,
+ branchName: string | null,
+ running: boolean,
+ maxConcurrency?: number,
+ runningTasks?: string[]
+ ) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const current = get().autoModeByWorktree;
+ const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
+ branchName,
+ maxConcurrency: maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
};
set({
- autoModeByProject: {
+ autoModeByWorktree: {
...current,
- [projectId]: { ...projectState, isRunning: running },
+ [worktreeKey]: {
+ ...worktreeState,
+ isRunning: running,
+ branchName,
+ maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
+ runningTasks: runningTasks ?? worktreeState.runningTasks,
+ },
},
});
},
- addRunningTask: (projectId, taskId) => {
- const current = get().autoModeByProject;
- const projectState = current[projectId] || {
+ addRunningTask: (projectId, branchName, taskId) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const current = get().autoModeByWorktree;
+ const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
+ branchName,
};
- if (!projectState.runningTasks.includes(taskId)) {
+ if (!worktreeState.runningTasks.includes(taskId)) {
set({
- autoModeByProject: {
+ autoModeByWorktree: {
...current,
- [projectId]: {
- ...projectState,
- runningTasks: [...projectState.runningTasks, taskId],
+ [worktreeKey]: {
+ ...worktreeState,
+ runningTasks: [...worktreeState.runningTasks, taskId],
+ branchName,
},
},
});
}
},
- removeRunningTask: (projectId, taskId) => {
- const current = get().autoModeByProject;
- const projectState = current[projectId] || {
+ removeRunningTask: (projectId, branchName, taskId) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const current = get().autoModeByWorktree;
+ const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
+ branchName,
};
set({
- autoModeByProject: {
+ autoModeByWorktree: {
...current,
- [projectId]: {
- ...projectState,
- runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
+ [worktreeKey]: {
+ ...worktreeState,
+ runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId),
+ branchName,
},
},
});
},
- clearRunningTasks: (projectId) => {
- const current = get().autoModeByProject;
- const projectState = current[projectId] || {
+ clearRunningTasks: (projectId, branchName) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const current = get().autoModeByWorktree;
+ const worktreeState = current[worktreeKey] || {
isRunning: false,
runningTasks: [],
+ branchName,
};
set({
- autoModeByProject: {
+ autoModeByWorktree: {
...current,
- [projectId]: { ...projectState, runningTasks: [] },
+ [worktreeKey]: { ...worktreeState, runningTasks: [], branchName },
},
});
},
- getAutoModeState: (projectId) => {
- const projectState = get().autoModeByProject[projectId];
- return projectState || { isRunning: false, runningTasks: [] };
+ getAutoModeState: (projectId, branchName) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const worktreeState = get().autoModeByWorktree[worktreeKey];
+ return (
+ worktreeState || {
+ isRunning: false,
+ runningTasks: [],
+ branchName,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
+ }
+ );
+ },
+
+ getMaxConcurrencyForWorktree: (projectId, branchName) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const worktreeState = get().autoModeByWorktree[worktreeKey];
+ return worktreeState?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
+ },
+
+ setMaxConcurrencyForWorktree: (projectId, branchName, maxConcurrency) => {
+ const worktreeKey = get().getWorktreeKey(projectId, branchName);
+ const current = get().autoModeByWorktree;
+ const worktreeState = current[worktreeKey] || {
+ isRunning: false,
+ runningTasks: [],
+ branchName,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
+ };
+ set({
+ autoModeByWorktree: {
+ ...current,
+ [worktreeKey]: { ...worktreeState, maxConcurrency, branchName },
+ },
+ });
},
addAutoModeActivity: (activity) => {
@@ -2417,6 +2704,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 });
@@ -2428,6 +2717,128 @@ export const useAppStore = create()((set, get) => ({
// Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }),
+ // Claude-Compatible Provider actions (new system)
+ addClaudeCompatibleProvider: async (provider) => {
+ set({ claudeCompatibleProviders: [...get().claudeCompatibleProviders, provider] });
+ // Sync immediately to persist provider
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ updateClaudeCompatibleProvider: async (id, updates) => {
+ set({
+ claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) =>
+ p.id === id ? { ...p, ...updates } : p
+ ),
+ });
+ // Sync immediately to persist changes
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ deleteClaudeCompatibleProvider: async (id) => {
+ set({
+ claudeCompatibleProviders: get().claudeCompatibleProviders.filter((p) => p.id !== id),
+ });
+ // Sync immediately to persist deletion
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ setClaudeCompatibleProviders: async (providers) => {
+ set({ claudeCompatibleProviders: providers });
+ // Sync immediately to persist providers
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ toggleClaudeCompatibleProviderEnabled: async (id) => {
+ set({
+ claudeCompatibleProviders: get().claudeCompatibleProviders.map((p) =>
+ p.id === id ? { ...p, enabled: p.enabled === false ? true : false } : p
+ ),
+ });
+ // Sync immediately to persist change
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ // Claude API Profile actions (deprecated - kept for backward compatibility)
+ addClaudeApiProfile: async (profile) => {
+ set({ claudeApiProfiles: [...get().claudeApiProfiles, profile] });
+ // Sync immediately to persist profile
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ updateClaudeApiProfile: async (id, updates) => {
+ set({
+ claudeApiProfiles: get().claudeApiProfiles.map((p) =>
+ p.id === id ? { ...p, ...updates } : p
+ ),
+ });
+ // Sync immediately to persist changes
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ deleteClaudeApiProfile: async (id) => {
+ const currentActiveId = get().activeClaudeApiProfileId;
+ const projects = get().projects;
+
+ // Find projects that have per-project override referencing the deleted profile
+ const affectedProjects = projects.filter((p) => p.activeClaudeApiProfileId === id);
+
+ // Update state: remove profile and clear references
+ set({
+ claudeApiProfiles: get().claudeApiProfiles.filter((p) => p.id !== id),
+ // Clear global active if the deleted profile was active
+ activeClaudeApiProfileId: currentActiveId === id ? null : currentActiveId,
+ // Clear per-project overrides that reference the deleted profile
+ projects: projects.map((p) =>
+ p.activeClaudeApiProfileId === id ? { ...p, activeClaudeApiProfileId: undefined } : p
+ ),
+ });
+
+ // Also update currentProject if it was using the deleted profile
+ const currentProject = get().currentProject;
+ if (currentProject?.activeClaudeApiProfileId === id) {
+ set({
+ currentProject: { ...currentProject, activeClaudeApiProfileId: undefined },
+ });
+ }
+
+ // Persist per-project changes to server (use __USE_GLOBAL__ marker)
+ const httpClient = getHttpApiClient();
+ await Promise.all(
+ affectedProjects.map((project) =>
+ httpClient.settings
+ .updateProject(project.path, { activeClaudeApiProfileId: '__USE_GLOBAL__' })
+ .catch((error) => {
+ console.error(`Failed to clear profile override for project ${project.name}:`, error);
+ })
+ )
+ );
+
+ // Sync global settings to persist deletion
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ setActiveClaudeApiProfile: async (id) => {
+ set({ activeClaudeApiProfileId: id });
+ // Sync immediately to persist active profile change
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
+ setClaudeApiProfiles: async (profiles) => {
+ set({ claudeApiProfiles: profiles });
+ // Sync immediately to persist profiles
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ await syncSettingsToServer();
+ },
+
// MCP Server actions
addMCPServer: (server) => {
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2656,12 +3067,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
@@ -2674,7 +3086,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,
@@ -2749,7 +3161,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 {
@@ -2879,6 +3291,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,
},
});
},
@@ -2970,6 +3384,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()}`;
@@ -3212,7 +3633,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;
@@ -3221,11 +3642,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',
@@ -3357,6 +3779,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/styles/global.css b/apps/ui/src/styles/global.css
index a8a6e53a..6e942b88 100644
--- a/apps/ui/src/styles/global.css
+++ b/apps/ui/src/styles/global.css
@@ -132,6 +132,7 @@
:root {
/* Default to light mode */
--radius: 0.625rem;
+ --perf-contain-intrinsic-size: 500px;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -1120,3 +1121,9 @@
animation: none;
}
}
+
+.perf-contain {
+ contain: layout paint;
+ content-visibility: auto;
+ contain-intrinsic-size: auto var(--perf-contain-intrinsic-size);
+}
diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts
index 49c1c4ad..f98f58a9 100644
--- a/apps/ui/src/types/electron.d.ts
+++ b/apps/ui/src/types/electron.d.ts
@@ -163,11 +163,30 @@ export interface SessionsAPI {
}
export type AutoModeEvent =
+ | {
+ type: 'auto_mode_started';
+ message: string;
+ projectPath?: string;
+ branchName?: string | null;
+ }
+ | {
+ type: 'auto_mode_stopped';
+ message: string;
+ projectPath?: string;
+ branchName?: string | null;
+ }
+ | {
+ type: 'auto_mode_idle';
+ message: string;
+ projectPath?: string;
+ branchName?: string | null;
+ }
| {
type: 'auto_mode_feature_start';
featureId: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
feature: unknown;
}
| {
@@ -175,6 +194,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
content: string;
}
| {
@@ -182,6 +202,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
tool: string;
input: unknown;
}
@@ -190,6 +211,7 @@ export type AutoModeEvent =
featureId: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
passes: boolean;
message: string;
}
@@ -197,6 +219,7 @@ export type AutoModeEvent =
type: 'pipeline_step_started';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
stepId: string;
stepName: string;
stepIndex: number;
@@ -206,6 +229,7 @@ export type AutoModeEvent =
type: 'pipeline_step_complete';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
stepId: string;
stepName: string;
stepIndex: number;
@@ -218,12 +242,14 @@ export type AutoModeEvent =
featureId?: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
}
| {
type: 'auto_mode_phase';
featureId: string;
projectId?: string;
projectPath?: string;
+ branchName?: string | null;
phase: 'planning' | 'action' | 'verification';
message: string;
}
@@ -231,6 +257,7 @@ export type AutoModeEvent =
type: 'auto_mode_ultrathink_preparation';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
warnings: string[];
recommendations: string[];
estimatedCost?: number;
@@ -240,6 +267,7 @@ export type AutoModeEvent =
type: 'plan_approval_required';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
planVersion?: number;
@@ -248,6 +276,7 @@ export type AutoModeEvent =
type: 'plan_auto_approved';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
}
@@ -255,6 +284,7 @@ export type AutoModeEvent =
type: 'plan_approved';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
hasEdits: boolean;
planVersion?: number;
}
@@ -262,12 +292,14 @@ export type AutoModeEvent =
type: 'plan_rejected';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
feedback?: string;
}
| {
type: 'plan_revision_requested';
featureId: string;
projectPath?: string;
+ branchName?: string | null;
feedback?: string;
hasEdits?: boolean;
planVersion?: number;
@@ -275,6 +307,7 @@ export type AutoModeEvent =
| {
type: 'planning_started';
featureId: string;
+ branchName?: string | null;
mode: 'lite' | 'spec' | 'full';
message: string;
}
@@ -389,18 +422,48 @@ export interface SpecRegenerationAPI {
}
export interface AutoModeAPI {
+ start: (
+ projectPath: string,
+ branchName?: string | null,
+ maxConcurrency?: number
+ ) => Promise<{
+ success: boolean;
+ message?: string;
+ alreadyRunning?: boolean;
+ branchName?: string | null;
+ error?: string;
+ }>;
+
+ stop: (
+ projectPath: string,
+ branchName?: string | null
+ ) => Promise<{
+ success: boolean;
+ message?: string;
+ wasRunning?: boolean;
+ runningFeaturesCount?: number;
+ branchName?: string | null;
+ error?: string;
+ }>;
+
stopFeature: (featureId: string) => Promise<{
success: boolean;
error?: string;
}>;
- status: (projectPath?: string) => Promise<{
+ status: (
+ projectPath?: string,
+ branchName?: string | null
+ ) => Promise<{
success: boolean;
isRunning?: boolean;
+ isAutoLoopRunning?: boolean;
currentFeatureId?: string | null;
runningFeatures?: string[];
runningProjects?: string[];
runningCount?: number;
+ maxConcurrency?: number;
+ branchName?: string | null;
error?: string;
}>;
@@ -665,18 +728,25 @@ export interface FileDiffResult {
}
export interface WorktreeAPI {
- // Merge worktree branch into main and clean up
+ // Merge worktree branch into a target branch (defaults to 'main') and optionally clean up
mergeFeature: (
projectPath: string,
branchName: string,
worktreePath: string,
+ targetBranch?: string,
options?: {
squash?: boolean;
message?: string;
+ deleteWorktreeAndBranch?: boolean;
}
) => Promise<{
success: boolean;
mergedBranch?: string;
+ targetBranch?: string;
+ deleted?: {
+ worktreeDeleted: boolean;
+ branchDeleted: boolean;
+ };
error?: string;
}>;
@@ -786,7 +856,8 @@ export interface WorktreeAPI {
// Push a worktree branch to remote
push: (
worktreePath: string,
- force?: boolean
+ force?: boolean,
+ remote?: string
) => Promise<{
success: boolean;
result?: {
@@ -879,6 +950,7 @@ export interface WorktreeAPI {
}>;
aheadCount: number;
behindCount: number;
+ hasRemoteBranch: boolean;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
@@ -899,6 +971,23 @@ export interface WorktreeAPI {
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
}>;
+ // List all remotes and their branches
+ listRemotes: (worktreePath: string) => Promise<{
+ success: boolean;
+ result?: {
+ remotes: Array<{
+ name: string;
+ url: string;
+ branches: Array<{
+ name: string;
+ fullRef: string;
+ }>;
+ }>;
+ };
+ error?: string;
+ code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
+ }>;
+
// Open a worktree directory in the editor
openInEditor: (
worktreePath: string,
@@ -946,6 +1035,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;
@@ -1113,6 +1254,19 @@ export interface WorktreeAPI {
payload: unknown;
}) => void
) => () => void;
+
+ // Discard changes for a worktree
+ discardChanges: (worktreePath: string) => Promise<{
+ success: boolean;
+ result?: {
+ discarded: boolean;
+ filesDiscarded: number;
+ filesRemaining: number;
+ branch: string;
+ message: string;
+ };
+ error?: string;
+ }>;
}
export interface GitAPI {
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/features/list-view-priority.spec.ts b/apps/ui/tests/features/list-view-priority.spec.ts
new file mode 100644
index 00000000..68e5be54
--- /dev/null
+++ b/apps/ui/tests/features/list-view-priority.spec.ts
@@ -0,0 +1,168 @@
+/**
+ * List View Priority Column E2E Test
+ *
+ * Verifies that the list view shows a priority column and allows sorting by priority
+ */
+
+import { test, expect } from '@playwright/test';
+import * as fs from 'fs';
+import * as path from 'path';
+import {
+ createTempDirPath,
+ cleanupTempDir,
+ setupRealProject,
+ waitForNetworkIdle,
+ authenticateForTests,
+ handleLoginScreenIfPresent,
+} from '../utils';
+
+const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
+
+// TODO: This test is skipped because setupRealProject only sets localStorage,
+// but the server's settings.json (set by setup-e2e-fixtures.mjs) takes precedence
+// with localStorageMigrated: true. The test creates features in a temp directory,
+// but the server loads from the E2E Test Project fixture path.
+// Fix: Either modify setupRealProject to also update server settings, or
+// have the test add features through the UI instead of on disk.
+test.describe.skip('List View Priority Column', () => {
+ let projectPath: string;
+ const projectName = `test-project-${Date.now()}`;
+
+ test.beforeAll(async () => {
+ if (!fs.existsSync(TEST_TEMP_DIR)) {
+ fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
+ }
+
+ projectPath = path.join(TEST_TEMP_DIR, projectName);
+ fs.mkdirSync(projectPath, { recursive: true });
+
+ fs.writeFileSync(
+ path.join(projectPath, 'package.json'),
+ JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
+ );
+
+ const automakerDir = path.join(projectPath, '.automaker');
+ fs.mkdirSync(automakerDir, { recursive: true });
+ const featuresDir = path.join(automakerDir, 'features');
+ fs.mkdirSync(featuresDir, { recursive: true });
+ fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
+
+ // Create test features with different priorities
+ const features = [
+ {
+ id: 'feature-high-priority',
+ description: 'High priority feature',
+ priority: 1,
+ status: 'backlog',
+ category: 'test',
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: 'feature-medium-priority',
+ description: 'Medium priority feature',
+ priority: 2,
+ status: 'backlog',
+ category: 'test',
+ createdAt: new Date().toISOString(),
+ },
+ {
+ id: 'feature-low-priority',
+ description: 'Low priority feature',
+ priority: 3,
+ status: 'backlog',
+ category: 'test',
+ createdAt: new Date().toISOString(),
+ },
+ ];
+
+ // Write each feature to its own directory
+ for (const feature of features) {
+ const featureDir = path.join(featuresDir, feature.id);
+ fs.mkdirSync(featureDir, { recursive: true });
+ fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(feature, null, 2));
+ }
+
+ fs.writeFileSync(
+ path.join(automakerDir, 'categories.json'),
+ JSON.stringify({ categories: ['test'] }, null, 2)
+ );
+
+ fs.writeFileSync(
+ path.join(automakerDir, 'app_spec.txt'),
+ `# ${projectName}\n\nA test project for e2e testing.`
+ );
+ });
+
+ test.afterAll(async () => {
+ cleanupTempDir(TEST_TEMP_DIR);
+ });
+
+ test('should display priority column in list view and allow sorting', async ({ page }) => {
+ await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
+
+ // Authenticate before navigating
+ await authenticateForTests(page);
+ await page.goto('/board');
+ await page.waitForLoadState('load');
+ await handleLoginScreenIfPresent(page);
+ await waitForNetworkIdle(page);
+
+ await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
+
+ // Switch to list view
+ await page.click('[data-testid="view-toggle-list"]');
+ await page.waitForTimeout(500);
+
+ // Verify list view is active
+ await expect(page.locator('[data-testid="list-view"]')).toBeVisible({ timeout: 5000 });
+
+ // Verify priority column header exists
+ await expect(page.locator('[data-testid="list-header-priority"]')).toBeVisible();
+ await expect(page.locator('[data-testid="list-header-priority"]')).toContainText('Priority');
+
+ // Verify priority cells are displayed for our test features
+ await expect(
+ page.locator('[data-testid="list-row-priority-feature-high-priority"]')
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-testid="list-row-priority-feature-medium-priority"]')
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-testid="list-row-priority-feature-low-priority"]')
+ ).toBeVisible();
+
+ // Verify priority badges show H, M, L
+ const highPriorityCell = page.locator(
+ '[data-testid="list-row-priority-feature-high-priority"]'
+ );
+ const mediumPriorityCell = page.locator(
+ '[data-testid="list-row-priority-feature-medium-priority"]'
+ );
+ const lowPriorityCell = page.locator('[data-testid="list-row-priority-feature-low-priority"]');
+
+ await expect(highPriorityCell).toContainText('H');
+ await expect(mediumPriorityCell).toContainText('M');
+ await expect(lowPriorityCell).toContainText('L');
+
+ // Click on priority header to sort
+ await page.click('[data-testid="list-header-priority"]');
+ await page.waitForTimeout(300);
+
+ // Get all rows within the backlog group and verify they are sorted by priority
+ // (High priority first when sorted ascending by priority value 1, 2, 3)
+ const backlogGroup = page.locator('[data-testid="list-group-backlog"]');
+ const rows = backlogGroup.locator('[data-testid^="list-row-feature-"]');
+
+ // The first row should be high priority (value 1 = lowest number = first in ascending)
+ const firstRow = rows.first();
+ await expect(firstRow).toHaveAttribute('data-testid', 'list-row-feature-high-priority');
+
+ // Click again to reverse sort (descending - low priority first)
+ await page.click('[data-testid="list-header-priority"]');
+ await page.waitForTimeout(300);
+
+ // Now the first row should be low priority (value 3 = highest number = first in descending)
+ const firstRowDesc = rows.first();
+ await expect(firstRowDesc).toHaveAttribute('data-testid', 'list-row-feature-low-priority');
+ });
+});
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/docs/UNIFIED_API_KEY_PROFILES.md b/docs/UNIFIED_API_KEY_PROFILES.md
new file mode 100644
index 00000000..3463b9fb
--- /dev/null
+++ b/docs/UNIFIED_API_KEY_PROFILES.md
@@ -0,0 +1,323 @@
+# Claude Compatible Providers System
+
+This document describes the implementation of Claude Compatible Providers, allowing users to configure alternative API endpoints that expose Claude-compatible models to the application.
+
+## Overview
+
+Claude Compatible Providers allow Automaker to work with third-party API endpoints that implement Claude's API protocol. This enables:
+
+- **Cost savings**: Use providers like z.AI GLM or MiniMax at lower costs
+- **Alternative models**: Access models like GLM-4.7 or MiniMax M2.1 through familiar interfaces
+- **Flexibility**: Configure per-phase model selection to optimize for speed vs quality
+- **Project overrides**: Use different providers for different projects
+
+## Architecture
+
+### Type Definitions
+
+#### ClaudeCompatibleProvider
+
+```typescript
+export interface ClaudeCompatibleProvider {
+ id: string; // Unique identifier (UUID)
+ name: string; // Display name (e.g., "z.AI GLM")
+ baseUrl: string; // API endpoint URL
+ providerType?: string; // Provider type for icon/grouping (e.g., 'glm', 'minimax', 'openrouter')
+ apiKeySource?: ApiKeySource; // 'inline' | 'env' | 'credentials'
+ apiKey?: string; // API key (when apiKeySource = 'inline')
+ useAuthToken?: boolean; // Use ANTHROPIC_AUTH_TOKEN header
+ timeoutMs?: number; // Request timeout in milliseconds
+ disableNonessentialTraffic?: boolean; // Minimize non-essential API calls
+ enabled?: boolean; // Whether provider is active (default: true)
+ models?: ProviderModel[]; // Models exposed by this provider
+}
+```
+
+#### ProviderModel
+
+```typescript
+export interface ProviderModel {
+ id: string; // Model ID sent to API (e.g., "GLM-4.7")
+ displayName: string; // Display name in UI (e.g., "GLM 4.7")
+ mapsToClaudeModel?: ClaudeModelAlias; // Which Claude tier this replaces ('haiku' | 'sonnet' | 'opus')
+ capabilities?: {
+ supportsVision?: boolean; // Whether model supports image inputs
+ supportsThinking?: boolean; // Whether model supports extended thinking
+ maxThinkingLevel?: ThinkingLevel; // Maximum thinking level if supported
+ };
+}
+```
+
+#### PhaseModelEntry
+
+Phase model configuration now supports provider models:
+
+```typescript
+export interface PhaseModelEntry {
+ providerId?: string; // Provider ID (undefined = native Claude)
+ model: string; // Model ID or alias
+ thinkingLevel?: ThinkingLevel; // 'none' | 'low' | 'medium' | 'high'
+}
+```
+
+### Provider Templates
+
+Available provider templates in `CLAUDE_PROVIDER_TEMPLATES`:
+
+| Template | Provider Type | Base URL | Description |
+| ---------------- | ------------- | ------------------------------------ | ----------------------------- |
+| Direct Anthropic | anthropic | `https://api.anthropic.com` | Standard Anthropic API |
+| OpenRouter | openrouter | `https://openrouter.ai/api` | Access Claude and 300+ models |
+| z.AI GLM | glm | `https://api.z.ai/api/anthropic` | GLM models at lower cost |
+| MiniMax | minimax | `https://api.minimax.io/anthropic` | MiniMax M2.1 model |
+| MiniMax (China) | minimax | `https://api.minimaxi.com/anthropic` | MiniMax for China region |
+
+### Model Mappings
+
+Each provider model specifies which Claude model tier it maps to via `mapsToClaudeModel`:
+
+**z.AI GLM:**
+
+- `GLM-4.5-Air` → haiku
+- `GLM-4.7` → sonnet, opus
+
+**MiniMax:**
+
+- `MiniMax-M2.1` → haiku, sonnet, opus
+
+**OpenRouter:**
+
+- `anthropic/claude-3.5-haiku` → haiku
+- `anthropic/claude-3.5-sonnet` → sonnet
+- `anthropic/claude-3-opus` → opus
+
+## Server-Side Implementation
+
+### API Key Resolution
+
+The `buildEnv()` function in `claude-provider.ts` resolves API keys based on `apiKeySource`:
+
+```typescript
+function buildEnv(
+ providerConfig?: ClaudeCompatibleProvider,
+ credentials?: Credentials
+): Record {
+ if (providerConfig) {
+ let apiKey: string | undefined;
+ const source = providerConfig.apiKeySource ?? 'inline';
+
+ switch (source) {
+ case 'inline':
+ apiKey = providerConfig.apiKey;
+ break;
+ case 'env':
+ apiKey = process.env.ANTHROPIC_API_KEY;
+ break;
+ case 'credentials':
+ apiKey = credentials?.apiKeys?.anthropic;
+ break;
+ }
+ // ... build environment with resolved key
+ }
+}
+```
+
+### Provider Lookup
+
+The `getProviderByModelId()` helper resolves provider configuration from model IDs:
+
+```typescript
+export async function getProviderByModelId(
+ modelId: string,
+ settingsService: SettingsService,
+ logPrefix?: string
+): Promise<{
+ provider?: ClaudeCompatibleProvider;
+ resolvedModel?: string;
+ credentials?: Credentials;
+}>;
+```
+
+This is used by all routes that call the Claude SDK to:
+
+1. Check if the model ID belongs to a provider
+2. Get the provider configuration (baseUrl, auth, etc.)
+3. Resolve the `mapsToClaudeModel` for the SDK
+
+### Phase Model Resolution
+
+The `getPhaseModelWithOverrides()` helper gets effective phase model config:
+
+```typescript
+export async function getPhaseModelWithOverrides(
+ phaseKey: PhaseModelKey,
+ settingsService: SettingsService,
+ projectPath?: string,
+ logPrefix?: string
+): Promise<{
+ model: string;
+ thinkingLevel?: ThinkingLevel;
+ providerId?: string;
+ providerConfig?: ClaudeCompatibleProvider;
+ credentials?: Credentials;
+}>;
+```
+
+This handles:
+
+1. Project-level overrides (if projectPath provided)
+2. Global phase model settings
+3. Default fallback models
+
+## UI Implementation
+
+### Model Selection Dropdowns
+
+Phase model selectors (`PhaseModelSelector`) display:
+
+1. **Claude Models** - Native Claude models (Haiku, Sonnet, Opus)
+2. **Provider Sections** - Each enabled provider as a separate group:
+ - Section header: `{provider.name} (via Claude)`
+ - Models with their mapped Claude tiers: "Maps to Haiku, Sonnet, Opus"
+ - Thinking level submenu for models that support it
+
+### Provider Icons
+
+Icons are determined by `providerType`:
+
+- `glm` → Z logo
+- `minimax` → MiniMax logo
+- `openrouter` → OpenRouter logo
+- Generic → OpenRouter as fallback
+
+### Bulk Replace
+
+The "Bulk Replace" feature allows switching all phase models to a provider at once:
+
+1. Select a provider from the dropdown
+2. Preview shows which models will be assigned:
+ - haiku phases → provider's haiku-mapped model
+ - sonnet phases → provider's sonnet-mapped model
+ - opus phases → provider's opus-mapped model
+3. Apply replaces all phase model configurations
+
+The Bulk Replace button only appears when at least one provider is enabled.
+
+## Project-Level Overrides
+
+Projects can override global phase model settings via `phaseModelOverrides`:
+
+```typescript
+interface Project {
+ // ...
+ phaseModelOverrides?: PhaseModelConfig; // Per-phase overrides
+}
+```
+
+### Storage
+
+Project overrides are stored in `.automaker/settings.json`:
+
+```json
+{
+ "phaseModelOverrides": {
+ "enhancementModel": {
+ "providerId": "provider-uuid",
+ "model": "GLM-4.5-Air",
+ "thinkingLevel": "none"
+ }
+ }
+}
+```
+
+### Resolution Priority
+
+1. Project override for specific phase (if set)
+2. Global phase model setting
+3. Default model for phase
+
+## Migration
+
+### v5 → v6 Migration
+
+The system migrated from `claudeApiProfiles` to `claudeCompatibleProviders`:
+
+```typescript
+// Old: modelMappings object
+{
+ modelMappings: {
+ haiku: 'GLM-4.5-Air',
+ sonnet: 'GLM-4.7',
+ opus: 'GLM-4.7'
+ }
+}
+
+// New: models array with mapsToClaudeModel
+{
+ models: [
+ { id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' },
+ { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' },
+ { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' },
+ ]
+}
+```
+
+The migration is automatic and preserves existing provider configurations.
+
+## Files Changed
+
+### Types
+
+| File | Changes |
+| ---------------------------- | -------------------------------------------------------------------- |
+| `libs/types/src/settings.ts` | `ClaudeCompatibleProvider`, `ProviderModel`, `PhaseModelEntry` types |
+| `libs/types/src/provider.ts` | `ExecuteOptions.claudeCompatibleProvider` field |
+| `libs/types/src/index.ts` | Exports for new types |
+
+### Server
+
+| File | Changes |
+| ---------------------------------------------- | -------------------------------------------------------- |
+| `apps/server/src/providers/claude-provider.ts` | Provider config handling, buildEnv updates |
+| `apps/server/src/lib/settings-helpers.ts` | `getProviderByModelId()`, `getPhaseModelWithOverrides()` |
+| `apps/server/src/services/settings-service.ts` | v5→v6 migration |
+| `apps/server/src/routes/**/*.ts` | Provider lookup for all SDK calls |
+
+### UI
+
+| File | Changes |
+| -------------------------------------------------- | ----------------------------------------- |
+| `apps/ui/src/.../phase-model-selector.tsx` | Provider model rendering, thinking levels |
+| `apps/ui/src/.../bulk-replace-dialog.tsx` | Bulk replace feature |
+| `apps/ui/src/.../api-profiles-section.tsx` | Provider management UI |
+| `apps/ui/src/components/ui/provider-icon.tsx` | Provider-specific icons |
+| `apps/ui/src/hooks/use-project-settings-loader.ts` | Load phaseModelOverrides |
+
+## Testing
+
+```bash
+# Build and run
+npm run build:packages
+npm run dev:web
+
+# Run server tests
+npm run test:server
+```
+
+### Test Cases
+
+1. **Provider setup**: Add z.AI GLM provider with inline API key
+2. **Model selection**: Select GLM-4.7 for a phase, verify it appears in dropdown
+3. **Thinking levels**: Select thinking level for provider model
+4. **Bulk replace**: Switch all phases to a provider at once
+5. **Project override**: Set per-project model override, verify it persists
+6. **Provider deletion**: Delete all providers, verify empty state persists
+
+## Future Enhancements
+
+Potential improvements:
+
+1. **Provider validation**: Test API connection before saving
+2. **Usage tracking**: Show which phases use which provider
+3. **Cost estimation**: Display estimated costs per provider
+4. **Model capabilities**: Auto-detect supported features from provider
diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts
index 63fd22e4..fcae1258 100644
--- a/libs/dependency-resolver/src/index.ts
+++ b/libs/dependency-resolver/src/index.ts
@@ -7,6 +7,8 @@ export {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
+ createFeatureMap,
+ getBlockingDependenciesFromMap,
wouldCreateCircularDependency,
dependencyExists,
getAncestors,
diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts
index 145617f4..02c87c26 100644
--- a/libs/dependency-resolver/src/resolver.ts
+++ b/libs/dependency-resolver/src/resolver.ts
@@ -229,6 +229,49 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[]
});
}
+/**
+ * Builds a lookup map for features by id.
+ *
+ * @param features - Features to index
+ * @returns Map keyed by feature id
+ */
+export function createFeatureMap(features: Feature[]): Map {
+ const featureMap = new Map();
+ for (const feature of features) {
+ if (feature?.id) {
+ featureMap.set(feature.id, feature);
+ }
+ }
+ return featureMap;
+}
+
+/**
+ * Gets the blocking dependencies using a precomputed feature map.
+ *
+ * @param feature - Feature to check
+ * @param featureMap - Map of all features by id
+ * @returns Array of feature IDs that are blocking this feature
+ */
+export function getBlockingDependenciesFromMap(
+ feature: Feature,
+ featureMap: Map
+): string[] {
+ const dependencies = feature.dependencies;
+ if (!dependencies || dependencies.length === 0) {
+ return [];
+ }
+
+ const blockingDependencies: string[] = [];
+ for (const depId of dependencies) {
+ const dep = featureMap.get(depId);
+ if (dep && dep.status !== 'completed' && dep.status !== 'verified') {
+ blockingDependencies.push(depId);
+ }
+ }
+
+ return blockingDependencies;
+}
+
/**
* Checks if adding a dependency from sourceId to targetId would create a circular dependency.
* When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies.
diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts
index 5f246b2a..7f6726f8 100644
--- a/libs/dependency-resolver/tests/resolver.test.ts
+++ b/libs/dependency-resolver/tests/resolver.test.ts
@@ -3,6 +3,8 @@ import {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
+ createFeatureMap,
+ getBlockingDependenciesFromMap,
wouldCreateCircularDependency,
dependencyExists,
} from '../src/resolver';
@@ -351,6 +353,21 @@ describe('resolver.ts', () => {
});
});
+ describe('getBlockingDependenciesFromMap', () => {
+ it('should match getBlockingDependencies when using a feature map', () => {
+ const dep1 = createFeature('Dep1', { status: 'pending' });
+ const dep2 = createFeature('Dep2', { status: 'completed' });
+ const dep3 = createFeature('Dep3', { status: 'running' });
+ const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] });
+ const allFeatures = [dep1, dep2, dep3, feature];
+ const featureMap = createFeatureMap(allFeatures);
+
+ expect(getBlockingDependenciesFromMap(feature, featureMap)).toEqual(
+ getBlockingDependencies(feature, allFeatures)
+ );
+ });
+ });
+
describe('wouldCreateCircularDependency', () => {
it('should return false for features with no existing dependencies', () => {
const features = [createFeature('A'), createFeature('B')];
diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts
index 6c636f98..d486d61b 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,75 +58,67 @@ 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
+ // 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;
+ }
+
+ // 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) => modelKey.startsWith(prefix)) ||
- (OPENAI_O_SERIES_PATTERN.test(modelKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(modelKey))
+ 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: ${modelKey}`);
- return modelKey;
+ console.log(`[ModelResolver] Using OpenAI/Codex model: ${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;
- }
-
- // Unknown model key - use default
- console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
- return defaultModel;
+ // Unknown model key - pass through as-is (could be a provider model like GLM-4.7, MiniMax-M2.1)
+ // This allows ClaudeCompatibleProvider models to work without being registered here
+ console.log(
+ `[ModelResolver] Unknown model key "${canonicalKey}", passing through unchanged (may be a provider model)`
+ );
+ return canonicalKey;
}
/**
@@ -143,6 +146,8 @@ export interface ResolvedPhaseModel {
model: string;
/** Optional thinking level for extended thinking */
thinkingLevel?: ThinkingLevel;
+ /** Provider ID if using a ClaudeCompatibleProvider */
+ providerId?: string;
}
/**
@@ -196,8 +201,23 @@ export function resolvePhaseModel(
// Handle new PhaseModelEntry object format
console.log(
- `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}"`
+ `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", providerId="${phaseModel.providerId}"`
);
+
+ // If providerId is set, pass through the model string unchanged
+ // (it's a provider-specific model ID like "GLM-4.5-Air", not a Claude alias)
+ if (phaseModel.providerId) {
+ console.log(
+ `[ModelResolver] Using provider model: providerId="${phaseModel.providerId}", model="${phaseModel.model}"`
+ );
+ return {
+ model: phaseModel.model, // Pass through unchanged
+ thinkingLevel: phaseModel.thinkingLevel,
+ providerId: phaseModel.providerId,
+ };
+ }
+
+ // No providerId - resolve through normal Claude model mapping
return {
model: resolveModelString(phaseModel.model, defaultModel),
thinkingLevel: phaseModel.thinkingLevel,
diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts
index 04452f83..84623b5b 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,47 +152,54 @@ 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);
}
});
});
- describe('with unknown model keys', () => {
- it('should return default for unknown model key', () => {
+ describe('with unknown model keys (provider models)', () => {
+ // Unknown models are now passed through unchanged to support
+ // ClaudeCompatibleProvider models like GLM-4.7, MiniMax-M2.1, etc.
+ it('should pass through unknown model key unchanged (may be provider model)', () => {
const result = resolveModelString('unknown-model');
- expect(result).toBe(DEFAULT_MODELS.claude);
+ expect(result).toBe('unknown-model');
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringContaining('passing through unchanged')
+ );
});
- it('should warn about unknown model key', () => {
+ it('should pass through provider-like model names', () => {
+ const glmModel = resolveModelString('GLM-4.7');
+ const minimaxModel = resolveModelString('MiniMax-M2.1');
+
+ expect(glmModel).toBe('GLM-4.7');
+ expect(minimaxModel).toBe('MiniMax-M2.1');
+ });
+
+ it('should not warn about unknown model keys (they are valid provider models)', () => {
resolveModelString('unknown-model');
- expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown model key'));
- expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-model'));
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
});
- it('should use custom default for unknown model key', () => {
+ it('should ignore custom default for unknown model key (passthrough takes precedence)', () => {
const customDefault = 'claude-opus-4-20241113';
const result = resolveModelString('truly-unknown-model', customDefault);
- expect(result).toBe(customDefault);
- });
-
- it('should warn and show default being used', () => {
- const customDefault = 'claude-custom-default';
- resolveModelString('invalid-key', customDefault);
-
- expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(customDefault));
+ // Unknown models pass through unchanged, default is not used
+ expect(result).toBe('truly-unknown-model');
});
});
@@ -198,17 +208,17 @@ describe('model-resolver', () => {
const resultUpper = resolveModelString('SONNET');
const resultLower = resolveModelString('sonnet');
- // Uppercase should not resolve (falls back to default)
- expect(resultUpper).toBe(DEFAULT_MODELS.claude);
- // Lowercase should resolve
+ // Uppercase is passed through (could be a provider model)
+ expect(resultUpper).toBe('SONNET');
+ // Lowercase should resolve to Claude model
expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet);
});
it('should handle mixed case in claude- strings', () => {
const result = resolveModelString('Claude-Sonnet-4-20250514');
- // Capital 'C' means it won't match 'claude-', falls back to default
- expect(result).toBe(DEFAULT_MODELS.claude);
+ // Capital 'C' means it won't match 'claude-', passed through as provider model
+ expect(result).toBe('Claude-Sonnet-4-20250514');
});
});
@@ -216,14 +226,15 @@ describe('model-resolver', () => {
it('should handle model key with whitespace', () => {
const result = resolveModelString(' sonnet ');
- // Will not match due to whitespace, falls back to default
- expect(result).toBe(DEFAULT_MODELS.claude);
+ // Will not match due to whitespace, passed through as-is (could be provider model)
+ expect(result).toBe(' sonnet ');
});
it('should handle special characters in model key', () => {
const result = resolveModelString('model@123');
- expect(result).toBe(DEFAULT_MODELS.claude);
+ // Passed through as-is (could be a provider model)
+ expect(result).toBe('model@123');
});
});
});
@@ -321,11 +332,11 @@ describe('model-resolver', () => {
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
});
- it('should handle fallback chain: unknown -> session -> default', () => {
- const result = getEffectiveModel('invalid', 'also-invalid', 'claude-opus-4-20241113');
+ it('should pass through unknown model (may be provider model)', () => {
+ const result = getEffectiveModel('GLM-4.7', 'also-unknown', 'claude-opus-4-20241113');
- // Both invalid models fall back to default
- expect(result).toBe('claude-opus-4-20241113');
+ // Unknown models pass through unchanged (could be provider models)
+ expect(result).toBe('GLM-4.7');
});
it('should handle session with alias, no explicit', () => {
@@ -519,19 +530,21 @@ describe('model-resolver', () => {
expect(result.thinkingLevel).toBeUndefined();
});
- it('should handle unknown model alias in entry', () => {
- const entry: PhaseModelEntry = { model: 'unknown-model' as any };
+ it('should pass through unknown model in entry (may be provider model)', () => {
+ const entry: PhaseModelEntry = { model: 'GLM-4.7' as any };
const result = resolvePhaseModel(entry);
- expect(result.model).toBe(DEFAULT_MODELS.claude);
+ // Unknown models pass through unchanged (could be provider models)
+ expect(result.model).toBe('GLM-4.7');
});
- it('should use custom default for unknown model in entry', () => {
- const entry: PhaseModelEntry = { model: 'invalid' as any, thinkingLevel: 'high' };
+ it('should pass through unknown model with thinkingLevel', () => {
+ const entry: PhaseModelEntry = { model: 'MiniMax-M2.1' as any, thinkingLevel: 'high' };
const customDefault = 'claude-haiku-4-5-20251001';
const result = resolvePhaseModel(entry, customDefault);
- expect(result.model).toBe(customDefault);
+ // Unknown models pass through, thinkingLevel is preserved
+ expect(result.model).toBe('MiniMax-M2.1');
expect(result.thinkingLevel).toBe('high');
});
});
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/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts
index f9849813..550f635d 100644
--- a/libs/prompts/src/defaults.ts
+++ b/libs/prompts/src/defaults.ts
@@ -339,7 +339,7 @@ IMPORTANT CONTEXT (automatically injected):
- When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with:
-1. Features to ADD (include title, description, category, and dependencies)
+1. Features to ADD (include id, title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId)
4. A summary of the changes
@@ -352,6 +352,7 @@ Respond with ONLY a JSON object in this exact format:
{
"type": "add",
"feature": {
+ "id": "descriptive-kebab-case-id",
"title": "Feature title",
"description": "Feature description",
"category": "feature" | "bug" | "enhancement" | "refactor",
@@ -386,6 +387,8 @@ Respond with ONLY a JSON object in this exact format:
\`\`\`
Important rules:
+- CRITICAL: For new features, always include a descriptive "id" in kebab-case (e.g., "user-authentication", "design-system-foundation")
+- Dependencies must reference these exact IDs - both for existing features and new features being added in the same plan
- Only include fields that need to change in updates
- Ensure dependency references are valid (don't reference deleted features)
- Provide clear, actionable descriptions
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 a0145782..a8f2644d 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,
@@ -158,6 +161,16 @@ export type {
EventHookHttpAction,
EventHookAction,
EventHook,
+ // Claude-compatible provider types (new)
+ ApiKeySource,
+ ClaudeCompatibleProviderType,
+ ClaudeModelAlias,
+ ProviderModel,
+ ClaudeCompatibleProvider,
+ ClaudeCompatibleProviderTemplate,
+ // Claude API profile types (deprecated)
+ ClaudeApiProfile,
+ ClaudeApiProfileTemplate,
} from './settings.js';
export {
DEFAULT_KEYBOARD_SHORTCUTS,
@@ -165,6 +178,7 @@ export {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
+ DEFAULT_MAX_CONCURRENCY,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
@@ -172,6 +186,10 @@ export {
getThinkingTokenBudget,
// Event hook constants
EVENT_HOOK_TRIGGER_LABELS,
+ // Claude-compatible provider templates (new)
+ CLAUDE_PROVIDER_TEMPLATES,
+ // Claude API profile constants (deprecated)
+ CLAUDE_API_PROFILE_TEMPLATES,
} from './settings.js';
// Model display constants
@@ -237,6 +255,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,
@@ -292,3 +322,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;
};
+ /**
+ * Active Claude API profile for alternative endpoint configuration.
+ * When set, uses profile's settings (base URL, auth, model mappings) instead of direct Anthropic API.
+ * When undefined, uses direct Anthropic API (via API key or Claude Max CLI OAuth).
+ * @deprecated Use claudeCompatibleProvider instead
+ */
+ claudeApiProfile?: ClaudeApiProfile;
+ /**
+ * Claude-compatible provider for alternative endpoint configuration.
+ * When set, uses provider's connection settings (base URL, auth) instead of direct Anthropic API.
+ * Models are passed directly without alias mapping.
+ * Takes precedence over claudeApiProfile if both are set.
+ */
+ claudeCompatibleProvider?: ClaudeCompatibleProvider;
+ /**
+ * Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers.
+ * When a profile/provider has apiKeySource='credentials', the Anthropic key from this object is used.
+ */
+ credentials?: Credentials;
}
/**
diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts
index a48504a8..8a10a6f8 100644
--- a/libs/types/src/settings.ts
+++ b/libs/types/src/settings.ts
@@ -101,6 +101,343 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
+// ============================================================================
+// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints
+// ============================================================================
+
+/**
+ * ApiKeySource - Strategy for sourcing API keys
+ *
+ * - 'inline': API key stored directly in the profile (legacy/default behavior)
+ * - 'env': Use ANTHROPIC_API_KEY environment variable
+ * - 'credentials': Use the Anthropic key from Settings → API Keys (credentials.json)
+ */
+export type ApiKeySource = 'inline' | 'env' | 'credentials';
+
+/**
+ * ClaudeCompatibleProviderType - Type of Claude-compatible provider
+ *
+ * Used to determine provider-specific UI screens and default configurations.
+ */
+export type ClaudeCompatibleProviderType =
+ | 'anthropic' // Direct Anthropic API (built-in)
+ | 'glm' // z.AI GLM
+ | 'minimax' // MiniMax
+ | 'openrouter' // OpenRouter proxy
+ | 'custom'; // User-defined custom provider
+
+/**
+ * ClaudeModelAlias - The three main Claude model aliases for mapping
+ */
+export type ClaudeModelAlias = 'haiku' | 'sonnet' | 'opus';
+
+/**
+ * ProviderModel - A model exposed by a Claude-compatible provider
+ *
+ * Each provider configuration can expose multiple models that will appear
+ * in all model dropdowns throughout the app. Models map directly to a
+ * Claude model (haiku, sonnet, opus) for bulk replace and display.
+ */
+export interface ProviderModel {
+ /** Model ID sent to the API (e.g., "GLM-4.7", "MiniMax-M2.1") */
+ id: string;
+ /** Display name shown in UI (e.g., "GLM 4.7", "MiniMax M2.1") */
+ displayName: string;
+ /** Which Claude model this maps to (for bulk replace and display) */
+ mapsToClaudeModel?: ClaudeModelAlias;
+ /** Model capabilities */
+ capabilities?: {
+ /** Whether model supports vision/image inputs */
+ supportsVision?: boolean;
+ /** Whether model supports extended thinking */
+ supportsThinking?: boolean;
+ /** Maximum thinking level if thinking is supported */
+ maxThinkingLevel?: ThinkingLevel;
+ };
+}
+
+/**
+ * ClaudeCompatibleProvider - Configuration for a Claude-compatible API endpoint
+ *
+ * Providers expose their models to all model dropdowns in the app.
+ * Each provider has its own API configuration (endpoint, credentials, etc.)
+ */
+export interface ClaudeCompatibleProvider {
+ /** Unique identifier (uuid) */
+ id: string;
+ /** Display name (e.g., "z.AI GLM (Work)", "MiniMax") */
+ name: string;
+ /** Provider type determines UI screen and default settings */
+ providerType: ClaudeCompatibleProviderType;
+ /** Whether this provider is enabled (models appear in dropdowns) */
+ enabled?: boolean;
+
+ // Connection settings
+ /** ANTHROPIC_BASE_URL - custom API endpoint */
+ baseUrl: string;
+ /** API key sourcing strategy */
+ apiKeySource: ApiKeySource;
+ /** API key value (only required when apiKeySource = 'inline') */
+ apiKey?: string;
+ /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */
+ useAuthToken?: boolean;
+ /** API_TIMEOUT_MS override in milliseconds */
+ timeoutMs?: number;
+ /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */
+ disableNonessentialTraffic?: boolean;
+
+ /** Models exposed by this provider (appear in all dropdowns) */
+ models: ProviderModel[];
+
+ /** Provider-specific settings for future extensibility */
+ providerSettings?: Record;
+}
+
+/**
+ * ClaudeApiProfile - Configuration for a Claude-compatible API endpoint
+ *
+ * @deprecated Use ClaudeCompatibleProvider instead. This type is kept for
+ * backward compatibility during migration.
+ */
+export interface ClaudeApiProfile {
+ /** Unique identifier (uuid) */
+ id: string;
+ /** Display name (e.g., "z.AI GLM", "AWS Bedrock") */
+ name: string;
+ /** ANTHROPIC_BASE_URL - custom API endpoint */
+ baseUrl: string;
+ /**
+ * API key sourcing strategy (default: 'inline' for backwards compatibility)
+ * - 'inline': Use apiKey field value
+ * - 'env': Use ANTHROPIC_API_KEY environment variable
+ * - 'credentials': Use the Anthropic key from credentials.json
+ */
+ apiKeySource?: ApiKeySource;
+ /** API key value (only required when apiKeySource = 'inline' or undefined) */
+ apiKey?: string;
+ /** If true, use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY */
+ useAuthToken?: boolean;
+ /** API_TIMEOUT_MS override in milliseconds */
+ timeoutMs?: number;
+ /** Optional model name mappings (deprecated - use ClaudeCompatibleProvider.models instead) */
+ modelMappings?: {
+ /** Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL */
+ haiku?: string;
+ /** Maps to ANTHROPIC_DEFAULT_SONNET_MODEL */
+ sonnet?: string;
+ /** Maps to ANTHROPIC_DEFAULT_OPUS_MODEL */
+ opus?: string;
+ };
+ /** Set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 */
+ disableNonessentialTraffic?: boolean;
+}
+
+/**
+ * ClaudeCompatibleProviderTemplate - Template for quick provider setup
+ *
+ * Contains pre-configured settings for known Claude-compatible providers.
+ */
+export interface ClaudeCompatibleProviderTemplate {
+ /** Template identifier for matching */
+ templateId: ClaudeCompatibleProviderType;
+ /** Display name for the template */
+ name: string;
+ /** Provider type */
+ providerType: ClaudeCompatibleProviderType;
+ /** API base URL */
+ baseUrl: string;
+ /** Default API key source for this template */
+ defaultApiKeySource: ApiKeySource;
+ /** Use auth token instead of API key */
+ useAuthToken: boolean;
+ /** Timeout in milliseconds */
+ timeoutMs?: number;
+ /** Disable non-essential traffic */
+ disableNonessentialTraffic?: boolean;
+ /** Description shown in UI */
+ description: string;
+ /** URL to get API key */
+ apiKeyUrl?: string;
+ /** Default models for this provider */
+ defaultModels: ProviderModel[];
+}
+
+/** Predefined templates for known Claude-compatible providers */
+export const CLAUDE_PROVIDER_TEMPLATES: ClaudeCompatibleProviderTemplate[] = [
+ {
+ templateId: 'anthropic',
+ name: 'Direct Anthropic',
+ providerType: 'anthropic',
+ baseUrl: 'https://api.anthropic.com',
+ defaultApiKeySource: 'credentials',
+ useAuthToken: false,
+ description: 'Standard Anthropic API with your API key',
+ apiKeyUrl: 'https://console.anthropic.com/settings/keys',
+ defaultModels: [
+ { id: 'claude-haiku', displayName: 'Claude Haiku', mapsToClaudeModel: 'haiku' },
+ { id: 'claude-sonnet', displayName: 'Claude Sonnet', mapsToClaudeModel: 'sonnet' },
+ { id: 'claude-opus', displayName: 'Claude Opus', mapsToClaudeModel: 'opus' },
+ ],
+ },
+ {
+ templateId: 'openrouter',
+ name: 'OpenRouter',
+ providerType: 'openrouter',
+ baseUrl: 'https://openrouter.ai/api',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ description: 'Access Claude and 300+ models via OpenRouter',
+ apiKeyUrl: 'https://openrouter.ai/keys',
+ defaultModels: [
+ // OpenRouter users manually add model IDs
+ {
+ id: 'anthropic/claude-3.5-haiku',
+ displayName: 'Claude 3.5 Haiku',
+ mapsToClaudeModel: 'haiku',
+ },
+ {
+ id: 'anthropic/claude-3.5-sonnet',
+ displayName: 'Claude 3.5 Sonnet',
+ mapsToClaudeModel: 'sonnet',
+ },
+ { id: 'anthropic/claude-3-opus', displayName: 'Claude 3 Opus', mapsToClaudeModel: 'opus' },
+ ],
+ },
+ {
+ templateId: 'glm',
+ name: 'z.AI GLM',
+ providerType: 'glm',
+ baseUrl: 'https://api.z.ai/api/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ disableNonessentialTraffic: true,
+ description: '3× usage at fraction of cost via GLM Coding Plan',
+ apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
+ defaultModels: [
+ { id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' },
+ { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' },
+ { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' },
+ ],
+ },
+ {
+ templateId: 'minimax',
+ name: 'MiniMax',
+ providerType: 'minimax',
+ baseUrl: 'https://api.minimax.io/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ disableNonessentialTraffic: true,
+ description: 'MiniMax M2.1 coding model with extended context',
+ apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key',
+ defaultModels: [
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' },
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' },
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' },
+ ],
+ },
+ {
+ templateId: 'minimax',
+ name: 'MiniMax (China)',
+ providerType: 'minimax',
+ baseUrl: 'https://api.minimaxi.com/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ disableNonessentialTraffic: true,
+ description: 'MiniMax M2.1 for users in China',
+ apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
+ defaultModels: [
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'haiku' },
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'sonnet' },
+ { id: 'MiniMax-M2.1', displayName: 'MiniMax M2.1', mapsToClaudeModel: 'opus' },
+ ],
+ },
+];
+
+/**
+ * @deprecated Use ClaudeCompatibleProviderTemplate instead
+ */
+export interface ClaudeApiProfileTemplate {
+ name: string;
+ baseUrl: string;
+ defaultApiKeySource?: ApiKeySource;
+ useAuthToken: boolean;
+ timeoutMs?: number;
+ modelMappings?: ClaudeApiProfile['modelMappings'];
+ disableNonessentialTraffic?: boolean;
+ description: string;
+ apiKeyUrl?: string;
+}
+
+/**
+ * @deprecated Use CLAUDE_PROVIDER_TEMPLATES instead
+ */
+export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [
+ {
+ name: 'Direct Anthropic',
+ baseUrl: 'https://api.anthropic.com',
+ defaultApiKeySource: 'credentials',
+ useAuthToken: false,
+ description: 'Standard Anthropic API with your API key',
+ apiKeyUrl: 'https://console.anthropic.com/settings/keys',
+ },
+ {
+ name: 'OpenRouter',
+ baseUrl: 'https://openrouter.ai/api',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ description: 'Access Claude and 300+ models via OpenRouter',
+ apiKeyUrl: 'https://openrouter.ai/keys',
+ },
+ {
+ name: 'z.AI GLM',
+ baseUrl: 'https://api.z.ai/api/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ modelMappings: {
+ haiku: 'GLM-4.5-Air',
+ sonnet: 'GLM-4.7',
+ opus: 'GLM-4.7',
+ },
+ disableNonessentialTraffic: true,
+ description: '3× usage at fraction of cost via GLM Coding Plan',
+ apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list',
+ },
+ {
+ name: 'MiniMax',
+ baseUrl: 'https://api.minimax.io/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ modelMappings: {
+ haiku: 'MiniMax-M2.1',
+ sonnet: 'MiniMax-M2.1',
+ opus: 'MiniMax-M2.1',
+ },
+ disableNonessentialTraffic: true,
+ description: 'MiniMax M2.1 coding model with extended context',
+ apiKeyUrl: 'https://platform.minimax.io/user-center/basic-information/interface-key',
+ },
+ {
+ name: 'MiniMax (China)',
+ baseUrl: 'https://api.minimaxi.com/anthropic',
+ defaultApiKeySource: 'inline',
+ useAuthToken: true,
+ timeoutMs: 3000000,
+ modelMappings: {
+ haiku: 'MiniMax-M2.1',
+ sonnet: 'MiniMax-M2.1',
+ opus: 'MiniMax-M2.1',
+ },
+ disableNonessentialTraffic: true,
+ description: 'MiniMax M2.1 for users in China',
+ apiKeyUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key',
+ },
+];
+
// ============================================================================
// Event Hooks - Custom actions triggered by system events
// ============================================================================
@@ -209,8 +546,21 @@ const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = [];
* - Claude models: Use thinkingLevel for extended thinking
* - Codex models: Use reasoningEffort for reasoning intensity
* - Cursor models: Handle thinking internally
+ *
+ * For Claude-compatible provider models (GLM, MiniMax, OpenRouter, etc.),
+ * the providerId field specifies which provider configuration to use.
*/
export interface PhaseModelEntry {
+ /**
+ * Provider ID for Claude-compatible provider models.
+ * - undefined: Use native Anthropic API (no custom provider)
+ * - string: Use the specified ClaudeCompatibleProvider by ID
+ *
+ * Only required when using models from a ClaudeCompatibleProvider.
+ * Native Claude models (claude-haiku, claude-sonnet, claude-opus) and
+ * other providers (Cursor, Codex, OpenCode) don't need this field.
+ */
+ providerId?: string;
/** The model to use (supports Claude, Cursor, Codex, OpenCode, and dynamic provider IDs) */
model: ModelId;
/** Extended thinking level (only applies to Claude models, defaults to 'none') */
@@ -475,6 +825,10 @@ export interface GlobalSettings {
/** Terminal font family (undefined = use default Menlo/Monaco) */
terminalFontFamily?: string;
+ // Terminal Configuration
+ /** How to open terminals from "Open in Terminal" worktree action */
+ openTerminalMode?: 'newTab' | 'split';
+
// UI State Preferences
/** Whether sidebar is currently open */
sidebarOpen: boolean;
@@ -603,6 +957,10 @@ export interface GlobalSettings {
/** Default editor command for "Open In" action (null = auto-detect: Cursor > 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;
@@ -650,6 +1008,39 @@ export interface GlobalSettings {
* @see EventHook for configuration details
*/
eventHooks?: EventHook[];
+
+ // Claude-Compatible Providers Configuration
+ /**
+ * Claude-compatible provider configurations.
+ * Each provider exposes its models to all model dropdowns in the app.
+ * Models can be mixed across providers (e.g., use GLM for enhancements, Anthropic for generation).
+ */
+ claudeCompatibleProviders?: ClaudeCompatibleProvider[];
+
+ // Deprecated Claude API Profiles (kept for migration)
+ /**
+ * @deprecated Use claudeCompatibleProviders instead.
+ * Kept for backward compatibility during migration.
+ */
+ claudeApiProfiles?: ClaudeApiProfile[];
+
+ /**
+ * @deprecated No longer used. Models are selected per-phase via phaseModels.
+ * Each PhaseModelEntry can specify a providerId for provider-specific models.
+ */
+ activeClaudeApiProfileId?: string | null;
+
+ /**
+ * Per-worktree auto mode settings
+ * Key: "${projectId}::${branchName ?? '__main__'}"
+ */
+ autoModeByWorktree?: Record<
+ string,
+ {
+ maxConcurrency: number;
+ branchName: string | null;
+ }
+ >;
}
/**
@@ -780,43 +1171,70 @@ 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;
+
+ // Phase Model Overrides (per-project)
+ /**
+ * Override phase model settings for this project.
+ * Any phase not specified here falls back to global phaseModels setting.
+ * Allows per-project customization of which models are used for each task.
+ */
+ phaseModelOverrides?: Partial;
+
+ // Deprecated Claude API Profile Override
+ /**
+ * @deprecated Use phaseModelOverrides instead.
+ * Models are now selected per-phase via phaseModels/phaseModelOverrides.
+ * Each PhaseModelEntry can specify a providerId for provider-specific models.
+ */
+ activeClaudeApiProfileId?: string | null;
}
/**
* 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 */
-export const SETTINGS_VERSION = 4;
+export const SETTINGS_VERSION = 6;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
export const PROJECT_SETTINGS_VERSION = 1;
+/** Default maximum concurrent agents for auto mode */
+export const DEFAULT_MAX_CONCURRENCY = 1;
+
/** Default keyboard shortcut bindings */
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
board: 'K',
@@ -850,25 +1268,25 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
theme: 'dark',
sidebarOpen: true,
chatHistoryOpen: false,
- maxConcurrency: 3,
+ maxConcurrency: DEFAULT_MAX_CONCURRENCY,
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
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,
@@ -892,10 +1310,17 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
codexThreadId: undefined,
mcpServers: [],
defaultEditorCommand: null,
+ defaultTerminalId: null,
enableSkills: true,
skillsSources: ['user', 'project'],
enableSubagents: true,
subagentsSources: ['user', 'project'],
+ // New provider system
+ claudeCompatibleProviders: [],
+ // Deprecated - kept for migration
+ claudeApiProfiles: [],
+ activeClaudeApiProfileId: null,
+ autoModeByWorktree: {},
};
/** Default credentials (empty strings - user must provide API keys) */
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/libs/utils/src/atomic-writer.ts b/libs/utils/src/atomic-writer.ts
index fe07e5eb..9fc7ff4a 100644
--- a/libs/utils/src/atomic-writer.ts
+++ b/libs/utils/src/atomic-writer.ts
@@ -7,6 +7,7 @@
import { secureFs } from '@automaker/platform';
import path from 'path';
+import crypto from 'crypto';
import { createLogger } from './logger.js';
import { mkdirSafe } from './fs-utils.js';
@@ -99,7 +100,9 @@ export async function atomicWriteJson(
): Promise {
const { indent = 2, createDirs = false, backupCount = 0 } = options;
const resolvedPath = path.resolve(filePath);
- const tempPath = `${resolvedPath}.tmp.${Date.now()}`;
+ // Use timestamp + random suffix to ensure uniqueness even for concurrent writes
+ const uniqueSuffix = `${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
+ const tempPath = `${resolvedPath}.tmp.${uniqueSuffix}`;
// Create parent directories if requested
if (createDirs) {
diff --git a/libs/utils/src/string-utils.ts b/libs/utils/src/string-utils.ts
new file mode 100644
index 00000000..b0c2cf24
--- /dev/null
+++ b/libs/utils/src/string-utils.ts
@@ -0,0 +1,178 @@
+/**
+ * String utility functions for common text operations
+ */
+
+/**
+ * Truncate a string to a maximum length, adding an ellipsis if truncated
+ * @param str - The string to truncate
+ * @param maxLength - Maximum length of the result (including ellipsis)
+ * @param ellipsis - The ellipsis string to use (default: '...')
+ * @returns The truncated string
+ */
+export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
+ if (maxLength < ellipsis.length) {
+ throw new Error(
+ `maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
+ );
+ }
+
+ if (str.length <= maxLength) {
+ return str;
+ }
+
+ return str.slice(0, maxLength - ellipsis.length) + ellipsis;
+}
+
+/**
+ * Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
+ * @param str - The string to convert
+ * @returns The kebab-case string
+ */
+export function toKebabCase(str: string): string {
+ return str
+ .replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
+ .replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
+ .replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
+ .replace(/-+/g, '-') // collapse multiple hyphens
+ .replace(/^-|-$/g, '') // remove leading/trailing hyphens
+ .toLowerCase();
+}
+
+/**
+ * Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
+ * @param str - The string to convert
+ * @returns The camelCase string
+ */
+export function toCamelCase(str: string): string {
+ return str
+ .replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
+ .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
+ .replace(/^[A-Z]/, (char) => char.toLowerCase());
+}
+
+/**
+ * Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
+ * @param str - The string to convert
+ * @returns The PascalCase string
+ */
+export function toPascalCase(str: string): string {
+ const camel = toCamelCase(str);
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
+}
+
+/**
+ * Capitalize the first letter of a string
+ * @param str - The string to capitalize
+ * @returns The string with first letter capitalized
+ */
+export function capitalize(str: string): string {
+ if (str.length === 0) {
+ return str;
+ }
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+/**
+ * Remove duplicate whitespace from a string, preserving single spaces
+ * @param str - The string to clean
+ * @returns The string with duplicate whitespace removed
+ */
+export function collapseWhitespace(str: string): string {
+ return str.replace(/\s+/g, ' ').trim();
+}
+
+/**
+ * Check if a string is empty or contains only whitespace
+ * @param str - The string to check
+ * @returns True if the string is blank
+ */
+export function isBlank(str: string | null | undefined): boolean {
+ return str === null || str === undefined || str.trim().length === 0;
+}
+
+/**
+ * Check if a string is not empty and contains non-whitespace characters
+ * @param str - The string to check
+ * @returns True if the string is not blank
+ */
+export function isNotBlank(str: string | null | undefined): boolean {
+ return !isBlank(str);
+}
+
+/**
+ * Safely parse a string to an integer, returning a default value on failure
+ * @param str - The string to parse
+ * @param defaultValue - The default value if parsing fails (default: 0)
+ * @returns The parsed integer or the default value
+ */
+export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
+ if (isBlank(str)) {
+ return defaultValue;
+ }
+
+ const parsed = parseInt(str!, 10);
+ return isNaN(parsed) ? defaultValue : parsed;
+}
+
+/**
+ * Generate a slug from a string (URL-friendly identifier)
+ * @param str - The string to convert to a slug
+ * @param maxLength - Optional maximum length for the slug
+ * @returns The slugified string
+ */
+export function slugify(str: string, maxLength?: number): string {
+ let slug = str
+ .toLowerCase()
+ .normalize('NFD') // Normalize unicode characters
+ .replace(/[\u0300-\u036f]/g, '') // Remove diacritics
+ .replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ .replace(/-+/g, '-') // Collapse multiple hyphens
+ .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
+
+ if (maxLength !== undefined && slug.length > maxLength) {
+ // Truncate at word boundary if possible
+ slug = slug.slice(0, maxLength);
+ const lastHyphen = slug.lastIndexOf('-');
+ if (lastHyphen > maxLength * 0.5) {
+ slug = slug.slice(0, lastHyphen);
+ }
+ slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
+ }
+
+ return slug;
+}
+
+/**
+ * Escape special regex characters in a string
+ * @param str - The string to escape
+ * @returns The escaped string safe for use in a RegExp
+ */
+export function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Pluralize a word based on count
+ * @param word - The singular form of the word
+ * @param count - The count to base pluralization on
+ * @param pluralForm - Optional custom plural form (default: word + 's')
+ * @returns The word in singular or plural form
+ */
+export function pluralize(word: string, count: number, pluralForm?: string): string {
+ if (count === 1) {
+ return word;
+ }
+ return pluralForm || `${word}s`;
+}
+
+/**
+ * Format a count with its associated word (e.g., "1 item", "3 items")
+ * @param count - The count
+ * @param singular - The singular form of the word
+ * @param plural - Optional custom plural form
+ * @returns Formatted string with count and word
+ */
+export function formatCount(count: number, singular: string, plural?: string): string {
+ return `${count} ${pluralize(singular, count, plural)}`;
+}
diff --git a/libs/utils/tests/atomic-writer.test.ts b/libs/utils/tests/atomic-writer.test.ts
index 1efa57d5..33ed4b43 100644
--- a/libs/utils/tests/atomic-writer.test.ts
+++ b/libs/utils/tests/atomic-writer.test.ts
@@ -64,16 +64,17 @@ describe('atomic-writer.ts', () => {
await atomicWriteJson(filePath, data);
// Verify writeFile was called with temp file path and JSON content
+ // Format: .tmp.{timestamp}.{random-hex}
expect(secureFs.writeFile).toHaveBeenCalledTimes(1);
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
- expect(writeCall[0]).toMatch(/\.tmp\.\d+$/);
+ expect(writeCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(writeCall[1]).toBe(JSON.stringify(data, null, 2));
expect(writeCall[2]).toBe('utf-8');
// Verify rename was called with temp -> target
expect(secureFs.rename).toHaveBeenCalledTimes(1);
const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0];
- expect(renameCall[0]).toMatch(/\.tmp\.\d+$/);
+ expect(renameCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
expect(renameCall[1]).toBe(path.resolve(filePath));
});
diff --git a/package-lock.json b/package-lock.json
index 8fc7b149..c86ba4aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,6 @@
"tree-kill": "1.2.2"
},
"devDependencies": {
- "dmg-license": "^1.0.11",
"husky": "9.1.7",
"lint-staged": "16.2.7",
"prettier": "3.7.4",
@@ -26,6 +25,9 @@
},
"engines": {
"node": ">=22.0.0 <23.0.0"
+ },
+ "optionalDependencies": {
+ "dmg-license": "^1.0.11"
}
},
"apps/server": {
@@ -86,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",
@@ -125,7 +128,8 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-tooltip": "1.2.8",
- "@tanstack/react-query": "5.90.12",
+ "@tanstack/react-query": "^5.90.17",
+ "@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "1.141.6",
"@uiw/react-codemirror": "4.25.4",
"@xterm/addon-fit": "0.10.0",
@@ -559,6 +563,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",
@@ -654,6 +685,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
@@ -5560,9 +5595,19 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.90.12",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz",
- "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==",
+ "version": "5.90.19",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz",
+ "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.92.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz",
+ "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5570,12 +5615,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.90.12",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
- "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
+ "version": "5.90.19",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
+ "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.90.12"
+ "@tanstack/query-core": "5.90.19"
},
"funding": {
"type": "github",
@@ -5585,6 +5630,23 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.91.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz",
+ "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-devtools": "5.92.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.90.14",
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tanstack/react-router": {
"version": "1.141.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
@@ -6114,7 +6176,7 @@
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -6124,15 +6186,15 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@types/plist": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
"integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"@types/node": "*",
"xmlbuilder": ">=11.0.1"
@@ -6213,8 +6275,8 @@
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
"integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==",
- "dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -6719,7 +6781,7 @@
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -6921,7 +6983,7 @@
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -7003,7 +7065,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7013,7 +7075,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -7237,8 +7299,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=0.8"
}
@@ -7289,8 +7351,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"engines": {
"node": ">=8"
}
@@ -7363,7 +7425,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "github",
@@ -7537,7 +7599,7 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "github",
@@ -8033,8 +8095,8 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
"integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"slice-ansi": "^3.0.0",
"string-width": "^4.2.0"
@@ -8128,7 +8190,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -8141,7 +8203,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -8309,8 +8371,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
- "dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/cors": {
"version": "2.8.5",
@@ -8329,8 +8391,8 @@
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"buffer": "^5.1.0"
}
@@ -8792,8 +8854,8 @@
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz",
"integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"os": [
"darwin"
],
@@ -9057,7 +9119,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -9682,11 +9744,11 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
- "dev": true,
"engines": [
"node >=0.6.0"
],
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
@@ -9698,7 +9760,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
@@ -9724,6 +9786,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",
@@ -10648,8 +10728,8 @@
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
"integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"os": [
"darwin"
],
@@ -10678,7 +10758,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "github",
@@ -10866,7 +10946,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11132,7 +11212,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/json-schema-typed": {
@@ -11274,7 +11354,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11296,7 +11375,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11339,7 +11417,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11361,7 +11438,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11383,7 +11459,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11405,7 +11480,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11427,7 +11501,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11449,7 +11522,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11471,7 +11543,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -13077,8 +13148,8 @@
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
"integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
- "dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/node-api-version": {
"version": "0.2.1",
@@ -13677,7 +13748,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
"integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
@@ -13793,7 +13864,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -14593,8 +14664,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
"integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@@ -14608,7 +14679,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
@@ -14805,7 +14876,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -14850,7 +14921,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -14886,6 +14957,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",
@@ -15609,7 +15692,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
- "dev": true,
+ "devOptional": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
@@ -15709,8 +15792,8 @@
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
- "dev": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
@@ -16153,7 +16236,7 @@
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8.0"
diff --git a/package.json b/package.json
index 0756f868..f7388410 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 ee273348..a2029da3 100755
--- a/start-automaker.sh
+++ b/start-automaker.sh
@@ -9,7 +9,7 @@ set -e
# ============================================================================
# CONFIGURATION & CONSTANTS
# ============================================================================
-
+export $(grep -v '^#' .env | xargs)
APP_NAME="Automaker"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HISTORY_FILE="${HOME}/.automaker_launcher_history"
@@ -34,6 +34,7 @@ fi
# Port configuration
DEFAULT_WEB_PORT=3007
DEFAULT_SERVER_PORT=3008
+PORT_SEARCH_MAX_ATTEMPTS=100
WEB_PORT=$DEFAULT_WEB_PORT
SERVER_PORT=$DEFAULT_SERVER_PORT
@@ -453,6 +454,25 @@ is_port_in_use() {
[ -n "$pids" ] && [ "$pids" != " " ]
}
+# Find the next available port starting from a given port
+# Returns the port on stdout if found, nothing if all ports in range are busy
+# Exit code: 0 if found, 1 if no available port in range
+find_next_available_port() {
+ local start_port=$1
+ local port=$start_port
+
+ for ((i=0; i/dev/null || true
-
+ # Auto-discover available ports (no user interaction required)
local web_in_use=false
local server_in_use=false
@@ -506,72 +524,46 @@ check_ports() {
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
echo ""
+ local max_port
if [ "$web_in_use" = true ]; then
local pids
- pids=$(get_pids_on_port "$DEFAULT_WEB_PORT")
- echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_WEB_PORT is in use by process(es): $pids"
+ # Get PIDs and convert newlines to spaces for display
+ pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs)
+ echo "${C_YELLOW}Port $DEFAULT_WEB_PORT in use (PID: $pids), finding alternative...${RESET}"
+ max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then
+ echo "${C_RED}Error: No free web port in range ${DEFAULT_WEB_PORT}-${max_port}${RESET}"
+ exit 1
+ fi
fi
if [ "$server_in_use" = true ]; then
local pids
- pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT")
- echo "${C_YELLOW}⚠${RESET} Port $DEFAULT_SERVER_PORT is in use by process(es): $pids"
+ # Get PIDs and convert newlines to spaces for display
+ pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs)
+ echo "${C_YELLOW}Port $DEFAULT_SERVER_PORT in use (PID: $pids), finding alternative...${RESET}"
+ max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then
+ echo "${C_RED}Error: No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}${RESET}"
+ exit 1
+ fi
fi
+
+ # Ensure web and server ports don't conflict with each other
+ if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then
+ local conflict_start=$((SERVER_PORT + 1))
+ max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then
+ echo "${C_RED}Error: No free server port in range ${conflict_start}-${max_port}${RESET}"
+ exit 1
+ fi
+ fi
+
echo ""
-
- while true; do
- read -r -p "What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: " choice
- case "$choice" in
- [kK]|[kK][iI][lL][lL])
- if [ "$web_in_use" = true ]; then
- kill_port "$DEFAULT_WEB_PORT"
- else
- echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available"
- fi
- if [ "$server_in_use" = true ]; then
- kill_port "$DEFAULT_SERVER_PORT"
- else
- echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available"
- fi
- break
- ;;
- [uU]|[uU][sS][eE])
- # Collect both ports first
- read -r -p "Enter web port (default $DEFAULT_WEB_PORT): " input_web
- input_web=${input_web:-$DEFAULT_WEB_PORT}
- read -r -p "Enter server port (default $DEFAULT_SERVER_PORT): " input_server
- input_server=${input_server:-$DEFAULT_SERVER_PORT}
-
- # Validate both before assigning either
- if ! validate_port "$input_web" "Web port"; then
- continue
- fi
- if ! validate_port "$input_server" "Server port"; then
- continue
- fi
-
- # Assign atomically after both validated
- WEB_PORT=$input_web
- SERVER_PORT=$input_server
- echo "${C_GREEN}Using ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}"
- break
- ;;
- [cC]|[cC][aA][nN][cC][eE][lL])
- echo "${C_MUTE}Cancelled.${RESET}"
- exit 0
- ;;
- *)
- echo "${C_RED}Invalid choice. Please enter k, u, or c.${RESET}"
- ;;
- esac
- done
- echo ""
+ echo "${C_GREEN}✓ Auto-selected available ports: Web=$WEB_PORT, Server=$SERVER_PORT${RESET}"
else
echo "${C_GREEN}✓${RESET} Port $DEFAULT_WEB_PORT is available"
echo "${C_GREEN}✓${RESET} Port $DEFAULT_SERVER_PORT is available"
fi
-
- hide_cursor
- stty -echo -icanon 2>/dev/null || true
}
validate_terminal_size() {
@@ -587,7 +579,7 @@ validate_terminal_size() {
echo "${C_YELLOW}⚠${RESET} Terminal size ${term_width}x${term_height} is smaller than recommended ${MIN_TERM_WIDTH}x${MIN_TERM_HEIGHT}"
echo " Some elements may not display correctly."
echo ""
- return 1
+ return 0
fi
}
@@ -791,37 +783,70 @@ resolve_port_conflicts() {
if is_port_in_use "$DEFAULT_WEB_PORT"; then
web_in_use=true
- web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT")
+ # Get PIDs and convert newlines to spaces for display
+ web_pids=$(get_pids_on_port "$DEFAULT_WEB_PORT" | xargs)
fi
if is_port_in_use "$DEFAULT_SERVER_PORT"; then
server_in_use=true
- server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT")
+ # Get PIDs and convert newlines to spaces for display
+ server_pids=$(get_pids_on_port "$DEFAULT_SERVER_PORT" | xargs)
fi
if [ "$web_in_use" = true ] || [ "$server_in_use" = true ]; then
echo ""
if [ "$web_in_use" = true ]; then
- center_print "⚠ Port $DEFAULT_WEB_PORT is in use by process(es): $web_pids" "$C_YELLOW"
+ center_print "Port $DEFAULT_WEB_PORT in use (PID: $web_pids)" "$C_YELLOW"
fi
if [ "$server_in_use" = true ]; then
- center_print "⚠ Port $DEFAULT_SERVER_PORT is in use by process(es): $server_pids" "$C_YELLOW"
+ center_print "Port $DEFAULT_SERVER_PORT in use (PID: $server_pids)" "$C_YELLOW"
fi
echo ""
# Show options
center_print "What would you like to do?" "$C_WHITE"
echo ""
- center_print "[K] Kill processes and continue" "$C_GREEN"
- center_print "[U] Use different ports" "$C_MUTE"
- center_print "[C] Cancel" "$C_RED"
+ center_print "[Enter] Auto-select available ports (Recommended)" "$C_GREEN"
+ center_print "[K] Kill processes and use default ports" "$C_MUTE"
+ center_print "[C] Choose custom ports" "$C_MUTE"
+ center_print "[X] Cancel" "$C_RED"
echo ""
while true; do
local choice_pad=$(( (TERM_COLS - 20) / 2 ))
printf "%${choice_pad}s" ""
- read -r -p "Choice: " choice
+ read -r -p "Choice [Enter]: " choice
case "$choice" in
+ ""|[aA]|[aA][uU][tT][oO])
+ # Auto-select: find next available ports
+ echo ""
+ local max_port=$((DEFAULT_WEB_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if [ "$web_in_use" = true ]; then
+ if ! WEB_PORT=$(find_next_available_port "$DEFAULT_WEB_PORT"); then
+ center_print "No free web port in range ${DEFAULT_WEB_PORT}-${max_port}" "$C_RED"
+ exit 1
+ fi
+ fi
+ max_port=$((DEFAULT_SERVER_PORT + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if [ "$server_in_use" = true ]; then
+ if ! SERVER_PORT=$(find_next_available_port "$DEFAULT_SERVER_PORT"); then
+ center_print "No free server port in range ${DEFAULT_SERVER_PORT}-${max_port}" "$C_RED"
+ exit 1
+ fi
+ fi
+ # Ensure web and server ports don't conflict with each other
+ if [ "$WEB_PORT" -eq "$SERVER_PORT" ]; then
+ local conflict_start=$((SERVER_PORT + 1))
+ max_port=$((conflict_start + PORT_SEARCH_MAX_ATTEMPTS - 1))
+ if ! SERVER_PORT=$(find_next_available_port "$conflict_start"); then
+ center_print "No free server port in range ${conflict_start}-${max_port}" "$C_RED"
+ exit 1
+ fi
+ fi
+ center_print "✓ Auto-selected available ports:" "$C_GREEN"
+ center_print " Web: $WEB_PORT | Server: $SERVER_PORT" "$C_PRI"
+ break
+ ;;
[kK]|[kK][iI][lL][lL])
echo ""
if [ "$web_in_use" = true ]; then
@@ -836,7 +861,7 @@ resolve_port_conflicts() {
fi
break
;;
- [uU]|[uU][sS][eE])
+ [cC]|[cC][hH][oO][oO][sS][eE])
echo ""
local input_pad=$(( (TERM_COLS - 40) / 2 ))
# Collect both ports first
@@ -861,14 +886,14 @@ resolve_port_conflicts() {
center_print "Using ports: Web=$WEB_PORT, Server=$SERVER_PORT" "$C_GREEN"
break
;;
- [cC]|[cC][aA][nN][cC][eE][lL])
+ [xX]|[xX][cC][aA][nN][cC][eE][lL])
echo ""
center_print "Cancelled." "$C_MUTE"
echo ""
exit 0
;;
*)
- center_print "Invalid choice. Please enter K, U, or C." "$C_RED"
+ center_print "Invalid choice. Press Enter for auto-select, or K/C/X." "$C_RED"
;;
esac
done
@@ -1129,9 +1154,11 @@ fi
# Execute the appropriate command
case $MODE in
web)
+ export $(grep -v '^#' .env | xargs)
export TEST_PORT="$WEB_PORT"
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
export PORT="$SERVER_PORT"
+ export DATA_DIR="$SCRIPT_DIR/data"
# Always include localhost and 127.0.0.1 for local dev, plus custom hostname if different
CORS_ORIGINS="http://localhost:$WEB_PORT,http://127.0.0.1:$WEB_PORT"
if [[ "$APP_HOST" != "localhost" && "$APP_HOST" != "127.0.0.1" ]]; then
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"
+}
diff --git a/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx
new file mode 100644
index 00000000..69392afa
--- /dev/null
+++ b/worktrees/automode-api/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx
@@ -0,0 +1,1582 @@
+import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { useAppStore } from '@/store/app-store';
+import { useIsMobile } from '@/hooks/use-media-query';
+import type {
+ ModelAlias,
+ CursorModelId,
+ CodexModelId,
+ OpencodeModelId,
+ GroupedModel,
+ PhaseModelEntry,
+} from '@automaker/types';
+import {
+ stripProviderPrefix,
+ STANDALONE_CURSOR_MODELS,
+ getModelGroup,
+ isGroupSelected,
+ getSelectedVariant,
+ codexModelHasThinking,
+} from '@automaker/types';
+import {
+ CLAUDE_MODELS,
+ CURSOR_MODELS,
+ OPENCODE_MODELS,
+ THINKING_LEVELS,
+ THINKING_LEVEL_LABELS,
+ REASONING_EFFORT_LEVELS,
+ REASONING_EFFORT_LABELS,
+ type ModelOption,
+} from '@/components/views/board-view/shared/model-constants';
+import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
+import {
+ AnthropicIcon,
+ CursorIcon,
+ OpenAIIcon,
+ getProviderIconForModel,
+} from '@/components/ui/provider-icon';
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+
+const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI';
+const OPENCODE_PROVIDER_FALLBACK = 'opencode';
+const OPENCODE_PROVIDER_WORD_SEPARATOR = '-';
+const OPENCODE_MODEL_ID_SEPARATOR = '/';
+const OPENCODE_SECTION_GROUP_PADDING = 'pt-2';
+
+const OPENCODE_STATIC_PROVIDER_LABELS: Record = {
+ [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
+};
+
+const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = {
+ 'github-copilot': 'GitHub Copilot',
+ 'zai-coding-plan': 'Z.AI Coding Plan',
+ google: 'Google AI',
+ openai: 'OpenAI',
+ openrouter: 'OpenRouter',
+ anthropic: 'Anthropic',
+ xai: 'xAI',
+ deepseek: 'DeepSeek',
+ ollama: 'Ollama (Local)',
+ lmstudio: 'LM Studio (Local)',
+ azure: 'Azure OpenAI',
+ [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
+};
+
+const OPENCODE_DYNAMIC_PROVIDER_ORDER = [
+ 'github-copilot',
+ 'google',
+ 'openai',
+ 'openrouter',
+ 'anthropic',
+ 'xai',
+ 'deepseek',
+ 'ollama',
+ 'lmstudio',
+ 'azure',
+ 'zai-coding-plan',
+];
+
+const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const;
+
+const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = {
+ free: 'Free Tier',
+ dynamic: 'Connected Providers',
+};
+
+const OPENCODE_STATIC_PROVIDER_BY_ID = new Map(
+ OPENCODE_MODELS.map((model) => [model.id, model.provider])
+);
+
+function formatProviderLabel(providerKey: string): string {
+ return providerKey
+ .split(OPENCODE_PROVIDER_WORD_SEPARATOR)
+ .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
+ .join(' ');
+}
+
+function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] {
+ if (providerKey === OPENCODE_PROVIDER_FALLBACK) {
+ return 'free';
+ }
+ return 'dynamic';
+}
+
+function getOpencodeGroupLabel(
+ providerKey: string,
+ sectionKey: (typeof OPENCODE_SECTION_ORDER)[number]
+): string {
+ if (sectionKey === 'free') {
+ return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier';
+ }
+ return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey);
+}
+
+interface PhaseModelSelectorProps {
+ /** Label shown in full mode */
+ label?: string;
+ /** Description shown in full mode */
+ description?: string;
+ /** Current model selection */
+ value: PhaseModelEntry;
+ /** Callback when model is selected */
+ onChange: (entry: PhaseModelEntry) => void;
+ /** Compact mode - just shows the button trigger without label/description wrapper */
+ compact?: boolean;
+ /** Custom trigger class name */
+ triggerClassName?: string;
+ /** Popover alignment */
+ align?: 'start' | 'end';
+ /** Disabled state */
+ disabled?: boolean;
+}
+
+export function PhaseModelSelector({
+ label,
+ description,
+ value,
+ onChange,
+ compact = false,
+ triggerClassName,
+ align = 'end',
+ disabled = false,
+}: PhaseModelSelectorProps) {
+ const [open, setOpen] = useState(false);
+ const [expandedGroup, setExpandedGroup] = useState(null);
+ const [expandedClaudeModel, setExpandedClaudeModel] = useState(null);
+ const [expandedCodexModel, setExpandedCodexModel] = useState(null);
+ const commandListRef = useRef(null);
+ const expandedTriggerRef = useRef(null);
+ const expandedClaudeTriggerRef = useRef(null);
+ const expandedCodexTriggerRef = useRef(null);
+ const {
+ enabledCursorModels,
+ favoriteModels,
+ toggleFavoriteModel,
+ codexModels,
+ codexModelsLoading,
+ fetchCodexModels,
+ dynamicOpencodeModels,
+ enabledDynamicModelIds,
+ opencodeModelsLoading,
+ fetchOpencodeModels,
+ disabledProviders,
+ } = useAppStore();
+
+ // Detect mobile devices to use inline expansion instead of nested popovers
+ const isMobile = useIsMobile();
+
+ // Extract model and thinking/reasoning levels from value
+ const selectedModel = value.model;
+ const selectedThinkingLevel = value.thinkingLevel || 'none';
+ const selectedReasoningEffort = value.reasoningEffort || 'none';
+
+ // Fetch Codex models on mount
+ useEffect(() => {
+ if (codexModels.length === 0 && !codexModelsLoading) {
+ fetchCodexModels().catch(() => {
+ // Silently fail - user will see empty Codex section
+ });
+ }
+ }, [codexModels.length, codexModelsLoading, fetchCodexModels]);
+
+ // Fetch OpenCode models on mount
+ useEffect(() => {
+ if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) {
+ fetchOpencodeModels().catch(() => {
+ // Silently fail - user will see only static OpenCode models
+ });
+ }
+ }, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]);
+
+ // Close expanded group when trigger scrolls out of view
+ useEffect(() => {
+ const triggerElement = expandedTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedGroup) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedGroup(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1, // Close when less than 10% visible
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedGroup]);
+
+ // Close expanded Claude model popover when trigger scrolls out of view
+ useEffect(() => {
+ const triggerElement = expandedClaudeTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedClaudeModel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedClaudeModel(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1,
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedClaudeModel]);
+
+ // Close expanded Codex model popover when trigger scrolls out of view
+ useEffect(() => {
+ const triggerElement = expandedCodexTriggerRef.current;
+ const listElement = commandListRef.current;
+ if (!triggerElement || !listElement || !expandedCodexModel) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (!entry.isIntersecting) {
+ setExpandedCodexModel(null);
+ }
+ },
+ {
+ root: listElement,
+ threshold: 0.1,
+ }
+ );
+
+ observer.observe(triggerElement);
+ return () => observer.disconnect();
+ }, [expandedCodexModel]);
+
+ // Transform dynamic Codex models from store to component format
+ const transformedCodexModels = useMemo(() => {
+ return codexModels.map((model) => ({
+ id: model.id,
+ label: model.label,
+ description: model.description,
+ provider: 'codex' as const,
+ badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined,
+ }));
+ }, [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) => {
+ return enabledCursorModels.includes(model.id as CursorModelId);
+ });
+
+ // Helper to find current selected model details
+ const currentModel = useMemo(() => {
+ const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
+ if (claudeModel) {
+ // Add thinking level to label if not 'none'
+ const thinkingLabel =
+ selectedThinkingLevel !== 'none'
+ ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
+ : '';
+ return {
+ ...claudeModel,
+ label: `${claudeModel.label}${thinkingLabel}`,
+ icon: AnthropicIcon,
+ };
+ }
+
+ // With canonical IDs, direct comparison works
+ const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
+ if (cursorModel) return { ...cursorModel, icon: CursorIcon };
+
+ // Check if selectedModel is part of a grouped model
+ const group = getModelGroup(selectedModel as CursorModelId);
+ if (group) {
+ const variant = getSelectedVariant(group, selectedModel as CursorModelId);
+ return {
+ id: selectedModel,
+ label: `${group.label} (${variant?.label || 'Unknown'})`,
+ description: group.description,
+ provider: 'cursor' as const,
+ icon: CursorIcon,
+ };
+ }
+
+ // Check Codex models
+ const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
+ if (codexModel) return { ...codexModel, icon: OpenAIIcon };
+
+ // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
+ const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
+ if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
+
+ // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons
+ const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel);
+ if (dynamicModel) {
+ return {
+ id: dynamicModel.id,
+ label: dynamicModel.name,
+ description: dynamicModel.description,
+ provider: 'opencode' as const,
+ icon: getProviderIconForModel(dynamicModel.id),
+ };
+ }
+
+ return null;
+ }, [
+ selectedModel,
+ selectedThinkingLevel,
+ availableCursorModels,
+ transformedCodexModels,
+ dynamicOpencodeModels,
+ ]);
+
+ // Compute grouped vs standalone Cursor models
+ const { groupedModels, standaloneCursorModels } = useMemo(() => {
+ const grouped: GroupedModel[] = [];
+ const standalone: typeof CURSOR_MODELS = [];
+ const seenGroups = new Set();
+
+ availableCursorModels.forEach((model) => {
+ const cursorId = model.id as CursorModelId;
+
+ // Check if this model is standalone
+ if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
+ standalone.push(model);
+ return;
+ }
+
+ // Check if this model belongs to a group
+ const group = getModelGroup(cursorId);
+ if (group && !seenGroups.has(group.baseId)) {
+ // Filter variants to only include enabled models
+ const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id));
+ if (enabledVariants.length > 0) {
+ grouped.push({
+ ...group,
+ variants: enabledVariants,
+ });
+ seenGroups.add(group.baseId);
+ }
+ }
+ });
+
+ return { groupedModels: grouped, standaloneCursorModels: standalone };
+ }, [availableCursorModels, enabledCursorModels]);
+
+ // Combine static and dynamic OpenCode models
+ const allOpencodeModels: ModelOption[] = useMemo(() => {
+ // Start with static models
+ const staticModels = [...OPENCODE_MODELS];
+
+ // Add dynamic models (convert ModelDefinition to ModelOption)
+ // Only include dynamic models that are enabled by the user
+ const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels
+ .filter((model) => enabledDynamicModelIds.includes(model.id))
+ .map((model) => ({
+ id: model.id,
+ label: model.name,
+ description: model.description,
+ badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
+ provider: 'opencode' as const,
+ }));
+
+ // Merge, avoiding duplicates (static models take precedence for same ID)
+ // In practice, static and dynamic IDs don't overlap
+ const staticIds = new Set(staticModels.map((m) => m.id));
+ const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
+
+ return [...staticModels, ...uniqueDynamic];
+ }, [dynamicOpencodeModels, enabledDynamicModelIds]);
+
+ // Group models (filtering out disabled providers)
+ const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
+ const favs: typeof CLAUDE_MODELS = [];
+ const cModels: typeof CLAUDE_MODELS = [];
+ const curModels: typeof CURSOR_MODELS = [];
+ const codModels: typeof transformedCodexModels = [];
+ const ocModels: ModelOption[] = [];
+
+ const isClaudeDisabled = disabledProviders.includes('claude');
+ const isCursorDisabled = disabledProviders.includes('cursor');
+ const isCodexDisabled = disabledProviders.includes('codex');
+ const isOpencodeDisabled = disabledProviders.includes('opencode');
+
+ // Process Claude Models (skip if provider is disabled)
+ if (!isClaudeDisabled) {
+ CLAUDE_MODELS.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ cModels.push(model);
+ }
+ });
+ }
+
+ // Process Cursor Models (skip if provider is disabled)
+ if (!isCursorDisabled) {
+ availableCursorModels.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ curModels.push(model);
+ }
+ });
+ }
+
+ // Process Codex Models (skip if provider is disabled)
+ if (!isCodexDisabled) {
+ transformedCodexModels.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ codModels.push(model);
+ }
+ });
+ }
+
+ // Process OpenCode Models (skip if provider is disabled)
+ if (!isOpencodeDisabled) {
+ allOpencodeModels.forEach((model) => {
+ if (favoriteModels.includes(model.id)) {
+ favs.push(model);
+ } else {
+ ocModels.push(model);
+ }
+ });
+ }
+
+ return {
+ favorites: favs,
+ claude: cModels,
+ cursor: curModels,
+ codex: codModels,
+ opencode: ocModels,
+ };
+ }, [
+ favoriteModels,
+ availableCursorModels,
+ transformedCodexModels,
+ allOpencodeModels,
+ disabledProviders,
+ ]);
+
+ // Group OpenCode models by model type for better organization
+ const opencodeSections = useMemo(() => {
+ type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number];
+ type OpencodeGroup = { key: string; label: string; models: ModelOption[] };
+ type OpencodeSection = {
+ key: OpencodeSectionKey;
+ label: string;
+ showGroupLabels: boolean;
+ groups: OpencodeGroup[];
+ };
+
+ const sections: Record> = {
+ free: {},
+ dynamic: {},
+ };
+ const dynamicProviderById = new Map(
+ dynamicOpencodeModels.map((model) => [model.id, model.provider])
+ );
+
+ const resolveProviderKey = (modelId: string): string => {
+ const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId);
+ if (staticProvider) return staticProvider;
+
+ const dynamicProvider = dynamicProviderById.get(modelId);
+ if (dynamicProvider) return dynamicProvider;
+
+ return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR)
+ ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0]
+ : OPENCODE_PROVIDER_FALLBACK;
+ };
+
+ const addModelToGroup = (
+ sectionKey: OpencodeSectionKey,
+ providerKey: string,
+ model: ModelOption
+ ) => {
+ if (!sections[sectionKey][providerKey]) {
+ sections[sectionKey][providerKey] = {
+ key: providerKey,
+ label: getOpencodeGroupLabel(providerKey, sectionKey),
+ models: [],
+ };
+ }
+ sections[sectionKey][providerKey].models.push(model);
+ };
+
+ opencode.forEach((model) => {
+ const providerKey = resolveProviderKey(model.id);
+ const sectionKey = getOpencodeSectionKey(providerKey);
+ addModelToGroup(sectionKey, providerKey, model);
+ });
+
+ const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => {
+ const groupMap = sections[sectionKey];
+ const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : [];
+ const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index]));
+
+ return Object.keys(groupMap)
+ .sort((a, b) => {
+ const aPriority = priorityMap.get(a);
+ const bPriority = priorityMap.get(b);
+
+ if (aPriority !== undefined && bPriority !== undefined) {
+ return aPriority - bPriority;
+ }
+ if (aPriority !== undefined) return -1;
+ if (bPriority !== undefined) return 1;
+
+ return groupMap[a].label.localeCompare(groupMap[b].label);
+ })
+ .map((key) => groupMap[key]);
+ };
+
+ const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => {
+ const groups = buildGroupList(sectionKey);
+ if (groups.length === 0) return null;
+
+ return {
+ key: sectionKey,
+ label: OPENCODE_SECTION_LABELS[sectionKey],
+ showGroupLabels: sectionKey !== 'free',
+ groups,
+ };
+ }).filter(Boolean) as OpencodeSection[];
+
+ return builtSections;
+ }, [opencode, dynamicOpencodeModels]);
+
+ // Render Codex model item with secondary popover for reasoning effort (only for models that support it)
+ const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
+ const isSelected = selectedModel === model.id;
+ const isFavorite = favoriteModels.includes(model.id);
+ const hasReasoning = codexModelHasThinking(model.id as CodexModelId);
+ const isExpanded = expandedCodexModel === model.id;
+ const currentReasoning = isSelected ? selectedReasoningEffort : 'none';
+
+ // If model doesn't support reasoning, render as simple selector (like Cursor models)
+ if (!hasReasoning) {
+ return (
+ {
+ onChange({ model: model.id as CodexModelId });
+ setOpen(false);
+ }}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+ {model.description}
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+ );
+ }
+
+ // Model supports reasoning - show popover with reasoning effort options
+ // On mobile, render inline expansion instead of nested popover
+ if (isMobile) {
+ return (
+
+
setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+
+ {isSelected && currentReasoning !== 'none'
+ ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
+ : model.description}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && !isExpanded && }
+
+
+
+
+ {/* Inline reasoning effort options on mobile */}
+ {isExpanded && (
+
+
+ Reasoning Effort
+
+ {REASONING_EFFORT_LEVELS.map((effort) => (
+
{
+ onChange({
+ model: model.id as CodexModelId,
+ reasoningEffort: effort,
+ });
+ setExpandedCodexModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {REASONING_EFFORT_LABELS[effort]}
+
+ {effort === 'none' && 'No reasoning capability'}
+ {effort === 'minimal' && 'Minimal reasoning'}
+ {effort === 'low' && 'Light reasoning'}
+ {effort === 'medium' && 'Moderate reasoning'}
+ {effort === 'high' && 'Deep reasoning'}
+ {effort === 'xhigh' && 'Maximum reasoning'}
+
+
+ {isSelected && currentReasoning === effort && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Desktop: Use nested popover
+ return (
+ setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedCodexModel(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {model.label}
+
+
+ {isSelected && currentReasoning !== 'none'
+ ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
+ : model.description}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ Reasoning Effort
+
+ {REASONING_EFFORT_LEVELS.map((effort) => (
+
{
+ onChange({
+ model: model.id as CodexModelId,
+ reasoningEffort: effort,
+ });
+ setExpandedCodexModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {REASONING_EFFORT_LABELS[effort]}
+
+ {effort === 'none' && 'No reasoning capability'}
+ {effort === 'minimal' && 'Minimal reasoning'}
+ {effort === 'low' && 'Light reasoning'}
+ {effort === 'medium' && 'Moderate reasoning'}
+ {effort === 'high' && 'Deep reasoning'}
+ {effort === 'xhigh' && 'Maximum reasoning'}
+
+
+ {isSelected && currentReasoning === effort && (
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ };
+
+ // Render OpenCode model item (simple selector, no thinking/reasoning options)
+ const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => {
+ const isSelected = selectedModel === model.id;
+ const isFavorite = favoriteModels.includes(model.id);
+
+ // Get the appropriate icon based on the specific model ID
+ const ProviderIcon = getProviderIconForModel(model.id);
+
+ return (
+ {
+ onChange({ model: model.id as OpencodeModelId });
+ setOpen(false);
+ }}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+ {model.description}
+
+
+
+
+ {model.badge && (
+
+ {model.badge}
+
+ )}
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+ );
+ };
+
+ // Render Cursor model item (no thinking level needed)
+ const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
+ // With canonical IDs, store the full prefixed ID
+ const isSelected = selectedModel === model.id;
+ const isFavorite = favoriteModels.includes(model.id);
+
+ return (
+ {
+ onChange({ model: model.id as CursorModelId });
+ setOpen(false);
+ }}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+ {model.description}
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+ );
+ };
+
+ // Render Claude model item with secondary popover for thinking level
+ const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => {
+ const isSelected = selectedModel === model.id;
+ const isFavorite = favoriteModels.includes(model.id);
+ const isExpanded = expandedClaudeModel === model.id;
+ const currentThinking = isSelected ? selectedThinkingLevel : 'none';
+
+ // On mobile, render inline expansion instead of nested popover
+ if (isMobile) {
+ return (
+
+
setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {model.label}
+
+
+ {isSelected && currentThinking !== 'none'
+ ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
+ : model.description}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && !isExpanded && }
+
+
+
+
+ {/* Inline thinking level options on mobile */}
+ {isExpanded && (
+
+
+ Thinking Level
+
+ {THINKING_LEVELS.map((level) => (
+
{
+ onChange({
+ model: model.id as ModelAlias,
+ thinkingLevel: level,
+ });
+ setExpandedClaudeModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {THINKING_LEVEL_LABELS[level]}
+
+ {level === 'none' && 'No extended thinking'}
+ {level === 'low' && 'Light reasoning (1k tokens)'}
+ {level === 'medium' && 'Moderate reasoning (10k tokens)'}
+ {level === 'high' && 'Deep reasoning (16k tokens)'}
+ {level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
+
+
+ {isSelected && currentThinking === level && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Desktop: Use nested popover
+ return (
+ setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedClaudeModel(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {model.label}
+
+
+ {isSelected && currentThinking !== 'none'
+ ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
+ : model.description}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavoriteModel(model.id);
+ }}
+ >
+
+
+ {isSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ Thinking Level
+
+ {THINKING_LEVELS.map((level) => (
+
{
+ onChange({
+ model: model.id as ModelAlias,
+ thinkingLevel: level,
+ });
+ setExpandedClaudeModel(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {THINKING_LEVEL_LABELS[level]}
+
+ {level === 'none' && 'No extended thinking'}
+ {level === 'low' && 'Light reasoning (1k tokens)'}
+ {level === 'medium' && 'Moderate reasoning (10k tokens)'}
+ {level === 'high' && 'Deep reasoning (16k tokens)'}
+ {level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
+
+
+ {isSelected && currentThinking === level && (
+
+ )}
+
+ ))}
+
+
+
+
+ );
+ };
+
+ // Render a grouped model with secondary popover for variant selection
+ const renderGroupedModelItem = (group: GroupedModel) => {
+ const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId);
+ const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId);
+ const isExpanded = expandedGroup === group.baseId;
+
+ const variantTypeLabel =
+ group.variantType === 'compute'
+ ? 'Compute Level'
+ : group.variantType === 'thinking'
+ ? 'Reasoning Mode'
+ : 'Capacity Options';
+
+ // On mobile, render inline expansion instead of nested popover
+ if (isMobile) {
+ return (
+
+
setExpandedGroup(isExpanded ? null : group.baseId)}
+ className="group flex items-center justify-between py-2"
+ >
+
+
+
+
+ {group.label}
+
+
+ {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
+
+
+
+
+
+ {groupIsSelected && !isExpanded && (
+
+ )}
+
+
+
+
+ {/* Inline variant options on mobile */}
+ {isExpanded && (
+
+
+ {variantTypeLabel}
+
+ {group.variants.map((variant) => (
+
{
+ onChange({ model: variant.id });
+ setExpandedGroup(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ selectedModel === variant.id && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {variant.label}
+ {variant.description && (
+
+ {variant.description}
+
+ )}
+
+
+ {variant.badge && (
+
+ {variant.badge}
+
+ )}
+ {selectedModel === variant.id && }
+
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Desktop: Use nested popover
+ return (
+ setExpandedGroup(isExpanded ? null : group.baseId)}
+ className="p-0 data-[selected=true]:bg-transparent"
+ >
+ {
+ if (!isOpen) {
+ setExpandedGroup(null);
+ }
+ }}
+ >
+
+
+
+
+
+
+ {group.label}
+
+
+ {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
+
+
+
+
+
+ {groupIsSelected && }
+
+
+
+
+ e.preventDefault()}
+ >
+
+
+ {variantTypeLabel}
+
+ {group.variants.map((variant) => (
+
{
+ onChange({ model: variant.id });
+ setExpandedGroup(null);
+ setOpen(false);
+ }}
+ className={cn(
+ 'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
+ 'hover:bg-accent cursor-pointer transition-colors',
+ selectedModel === variant.id && 'bg-accent text-accent-foreground'
+ )}
+ >
+
+ {variant.label}
+ {variant.description && (
+ {variant.description}
+ )}
+
+
+ {variant.badge && (
+
+ {variant.badge}
+
+ )}
+ {selectedModel === variant.id && }
+
+
+ ))}
+
+
+
+
+ );
+ };
+
+ // Compact trigger button (for agent view etc.)
+ const compactTrigger = (
+
+ {currentModel?.icon && }
+
+ {currentModel?.label?.replace('Claude ', '') || 'Select model...'}
+
+
+
+ );
+
+ // Full trigger button (for settings view)
+ const fullTrigger = (
+
+
+ {currentModel?.icon && }
+ {currentModel?.label || 'Select model...'}
+
+
+
+ );
+
+ // The popover content (shared between both modes)
+ const popoverContent = (
+ e.stopPropagation()}
+ onTouchMove={(e) => e.stopPropagation()}
+ onPointerDownOutside={(e) => {
+ // Only prevent close if clicking inside a nested popover (thinking level panel)
+ const target = e.target as HTMLElement;
+ if (target.closest('[data-slot="popover-content"]')) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+
+
+ No model found.
+
+ {favorites.length > 0 && (
+ <>
+
+ {(() => {
+ const renderedGroups = new Set();
+ return favorites.map((model) => {
+ // Check if this favorite is part of a grouped model
+ if (model.provider === 'cursor') {
+ const cursorId = model.id as CursorModelId;
+ const group = getModelGroup(cursorId);
+ if (group) {
+ // Skip if we already rendered this group
+ if (renderedGroups.has(group.baseId)) {
+ return null;
+ }
+ renderedGroups.add(group.baseId);
+ // Find the group in groupedModels (which has filtered variants)
+ const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId);
+ if (filteredGroup) {
+ return renderGroupedModelItem(filteredGroup);
+ }
+ }
+ // Standalone Cursor model
+ return renderCursorModelItem(model);
+ }
+ // Codex model
+ if (model.provider === 'codex') {
+ return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
+ }
+ // OpenCode model
+ if (model.provider === 'opencode') {
+ return renderOpencodeModelItem(model);
+ }
+ // Claude model
+ return renderClaudeModelItem(model);
+ });
+ })()}
+
+
+ >
+ )}
+
+ {claude.length > 0 && (
+
+ {claude.map((model) => renderClaudeModelItem(model))}
+
+ )}
+
+ {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
+
+ {/* Grouped models with secondary popover */}
+ {groupedModels.map((group) => renderGroupedModelItem(group))}
+ {/* Standalone models */}
+ {standaloneCursorModels.map((model) => renderCursorModelItem(model))}
+
+ )}
+
+ {codex.length > 0 && (
+
+ {codex.map((model) => renderCodexModelItem(model))}
+
+ )}
+
+ {opencodeSections.length > 0 && (
+
+ {opencodeSections.map((section, sectionIndex) => (
+
+
+ {section.label}
+
+
+ {section.groups.map((group) => (
+
+ {section.showGroupLabels && (
+
+ {group.label}
+
+ )}
+ {group.models.map((model) => renderOpencodeModelItem(model))}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+
+ // Compact mode - just the popover with compact trigger
+ if (compact) {
+ return (
+
+ {compactTrigger}
+ {popoverContent}
+
+ );
+ }
+
+ // Full mode - with label and description wrapper
+ return (
+
+ {/* Label and Description */}
+
+
{label}
+
{description}
+
+
+ {/* Model Selection Popover */}
+
+ {fullTrigger}
+ {popoverContent}
+
+
+ );
+}