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() { )} @@ -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() { )} @@ -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
- {cardOpacity}% + {localCardOpacity}%
- {columnOpacity}% + {localColumnOpacity}%
- {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 && (
- +

Loading projects...

)} - {error && !isLoading && ( + {errorMessage && !isLoading && (
-

{error}

-
)} - {!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) => ( @@ -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() {
-
- - - -
+ Thinking...
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 ? (
- +

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 (
- +

Applying changes...

); @@ -452,7 +443,7 @@ export function BacklogPlanDialog({ + {/* Set as Child - middle */} + + {/* Cancel - bottom */} + + + + + ); +} 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
  • +
+
- + @@ -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)} + /> + +
+ + {deleteWorktreeAndBranch && ( +
+ + + The worktree and branch will be permanently deleted. Any features assigned to this + branch will be unassigned. + +
+ )} + - 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({ +
+ ) : ( +
+
+
+ + +
+ +
+ +
+ + + {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. +

+
+ )} +
+ )} + + + + + + +
+ ); +} 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 ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ + {selectedRemote && ( +
+

+ This will create a new remote branch{' '} + + {selectedRemote}/{worktree?.branch} + {' '} + and set up tracking. +

+
+ )} +
+ )} + + + + + +
+
+ ); +} 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" /> - +
+ + {/* 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 */} + )} - )} +
+ ) : column.id === 'backlog' ? ( +
+ + +
+ ) : column.id === 'waiting_approval' ? ( -
- ) : column.id === 'backlog' ? ( -
- - -
- ) : column.id === 'waiting_approval' ? ( - - ) : column.id === 'in_progress' ? ( - - ) : column.isPipelineStep ? ( - - ) : undefined - } - footerAction={ - column.id === 'backlog' ? ( - - ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} + ) : column.id === 'in_progress' ? ( + + ) : column.isPipelineStep ? ( + + ) : undefined + } + footerAction={ + column.id === 'backlog' ? ( + + ) : 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 ? ( <>
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" >
- +

Opening project...

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 ( ); @@ -297,9 +297,7 @@ export function IssueDetailPanel({ Comments {totalCount > 0 && `(${totalCount})`} - {commentsLoading && ( - - )} + {commentsLoading && } {commentsExpanded ? ( ) : ( @@ -340,7 +338,7 @@ export function IssueDetailPanel({ > {loadingMore ? ( <> - + Loading... ) : ( diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx index bf6496f1..01bf8316 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -2,12 +2,12 @@ import { Circle, CheckCircle2, ExternalLink, - Loader2, CheckCircle, Sparkles, GitPullRequest, User, } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import type { IssueRowProps } from '../types'; @@ -97,7 +97,7 @@ export function IssueRow({ {/* Validating indicator */} {isValidating && ( - + Analyzing... )} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 1c58bbe4..5b599c4e 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,5 +1,6 @@ import { CircleDot, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import type { IssuesStateFilter } from '../types'; import { IssuesFilterControls } from './issues-filter-controls'; @@ -77,7 +78,7 @@ export function IssuesListHeader({
diff --git a/apps/ui/src/components/views/github-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'} +

@@ -196,9 +176,9 @@ export function GitHubPRsView() {
{selectedPR.state === 'MERGED' ? ( - + ) : ( - + )} #{selectedPR.number} {selectedPR.title} @@ -209,7 +189,7 @@ export function GitHubPRsView() { )}
-
+
+
+ + )} + + ); + } + 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 */} +
+ + +
+ + {/* 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 && ( +
+
+ + + {changeCount} of {ALL_PHASES.length} will be overridden + +
+
+ + + + + + + + + + + {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + + + + + + + ))} + +
PhaseCurrent + New Override +
{label}{currentDisplay} + {isChanged ? ( + + ) : ( + + )} + + + {newDisplay} + +
+
+
+ )} +
+ + + + + +
+
+ ); +} 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. +

+
+ +
+
+ + +

{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 && ( + + )} + +
+
+ ); +} + +function PhaseGroup({ + title, + subtitle, + phases, + project, +}: { + title: string; + subtitle: string; + phases: PhaseConfig[]; + project: Project; +}) { + const { phaseModels } = useAppStore(); + const projectOverrides = project.phaseModelOverrides || {}; + + return ( +
+
+

{title}

+

{subtitle}

+
+
+ {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 && ( + + )} + {overrideCount > 0 && ( + + )} +
+
+
+ + {/* 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
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() {

-
@@ -253,7 +201,12 @@ export function RunningAgentsView() { > View Project - @@ -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() {

{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() {

{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 */} +
+ + +
+ + {/* 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 && ( +
+
+ + + {changeCount} of {ALL_PHASES.length} will change + +
+
+ + + + + + + + + + + {preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => ( + + + + + + + ))} + +
PhaseCurrentNew
{label}{currentDisplay} + {isChanged ? ( + + ) : ( + + )} + + + {newDisplay} + +
+
+
+ )} +
+ + + + + +
+
+ ); +} 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 (
- +
+ {hasEnabledProviders && ( + + )} + +
+ {/* 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) => ( + + ))} +
+ )} +
+ ); + } + + // 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) => ( + + ))} +
+
+
+
+ ); + }; + // 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 +

+
+
+ + + + + + 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 */} +
+ + 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 && ( +
+ + +
+ )} + + {/* API Key - always shown first for fixed providers */} +
+ +
+ setFormData({ ...formData, apiKey: e.target.value })} + placeholder="Enter API key" + className="pr-10" + /> + +
+ {currentTemplate?.apiKeyUrl && ( + + Get API Key from {currentTemplate.name} + + )} +
+ + {/* Base URL - hidden for fixed providers since it's pre-configured */} + {!isFixedProvider && ( +
+ + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ )} + + {/* Advanced options for non-fixed providers only */} + {!isFixedProvider && ( + <> + {/* API Key Source */} +
+ + +
+ + {/* Use Auth Token */} +
+
+ +

+ Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY +

+
+ + setFormData({ ...formData, useAuthToken: checked }) + } + /> +
+ + {/* Disable Non-essential Traffic */} +
+
+ +

+ Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +

+
+ + setFormData({ ...formData, disableNonessentialTraffic: checked }) + } + /> +
+ + )} + + {/* Timeout */} +
+ + setFormData({ ...formData, timeoutMs: e.target.value })} + placeholder="Optional, e.g., 3000000" + /> +
+ + {/* Models */} +
+ {/* For fixed providers, show collapsible section */} + {isFixedProvider ? ( + <> +
+
+ +

+ {formData.models.length} mappings configured (Haiku, Sonnet, Opus) +

+
+ +
+ + {/* Expanded model mappings for fixed providers */} + {showModelMappings && ( +
+ {formData.models.map((model, index) => ( +
+
+
+
+ + handleUpdateModel(index, { id: e.target.value })} + placeholder="e.g., GLM-4.7" + className="text-xs h-8" + /> +
+
+ + + handleUpdateModel(index, { displayName: e.target.value }) + } + placeholder="e.g., GLM 4.7" + className="text-xs h-8" + /> +
+
+
+ + +
+
+ +
+ ))} + +
+ )} + + ) : ( + <> + {/* Non-fixed providers: always show full editing UI */} +
+
+ +

+ Map provider models to Claude equivalents (Haiku, Sonnet, Opus) +

+
+ +
+ + {/* 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) => ( +
+
+
+
+ + handleUpdateModel(index, { id: e.target.value })} + placeholder="e.g., GLM-4.7" + className="text-xs h-8" + /> +
+
+ + + handleUpdateModel(index, { displayName: e.target.value }) + } + placeholder="e.g., GLM 4.7" + className="text-xs h-8" + /> +
+
+
+ + +
+
+ +
+ ))} +
+ )} + + )} +
+
+ + + + + +
+
+ + {/* 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. + + + + + + + + +
+ ); +} + +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 */} +
+
+ + +
+

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

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

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

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

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

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

Verifying CLI authentication...

Running a test query

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

Verifying API key...

Running a test query

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

Verifying CLI authentication...

Running a test query

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

Verifying API key...

Running a test query

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

Checking Cursor CLI status...

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

Checking GitHub CLI status...

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

Checking OpenCode CLI status...

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

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

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

Checking Cursor CLI status...

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

Checking Codex CLI status...

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

Checking OpenCode CLI status...

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

Checking provider status...

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

{emptyMessage}

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