Merge pull request #635 from AutoMaker-Org/v0.13.0rc

V0.13.0rc
This commit is contained in:
Web Dev Cody
2026-01-20 18:48:46 -05:00
committed by GitHub
340 changed files with 24962 additions and 5124 deletions

View File

@@ -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)

View File

@@ -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

300
SECURITY_TODO.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
╠═════════════════════════════════════════════════════════════════════╣
║ ║
The Claude Agent SDK requires authentication to function.
${w1}
║ ║
Set your Anthropic API key:
export ANTHROPIC_API_KEY="sk-ant-..."
${w2}
${w3}
║ ║
Or use the setup wizard in Settings to configure authentication.
╚═══════════════════════════════════════════════════════════════════════╝
${w4}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
} else {
logger.info('✓ ANTHROPIC_API_KEY detected (API key auth)');
logger.info('✓ ANTHROPIC_API_KEY detected');
}
// Initialize security
@@ -175,15 +191,26 @@ app.use(
return;
}
// For local development, allow localhost origins
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
origin.startsWith('http://localhost:') ||
origin.startsWith('http://127.0.0.1:') ||
origin.startsWith('http://[::1]:')
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
callback(new Error('Not allowed by CORS'));
@@ -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. ║
╔═════════════════════════════════════════════════════════════════════
${eHeader}
╠═════════════════════════════════════════════════════════════════════
║ ║
To fix this, try one of:
${e1}
║ ║
1. Kill the process using the port:
║ lsof -ti:${port} | xargs kill -9 ║
${e2}
║ ║
2. Use a different port:
PORT=${port + 1} npm run dev:server
${e3}
${e4}
║ ║
3. Use the init.sh script which handles this:
./init.sh
╚═══════════════════════════════════════════════════════╝
${e5}
${e6}
║ ║
${e7}
${e8}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
process.exit(1);
} else {

View File

@@ -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<string, { createdAt: number; expiresAt: number }>();
@@ -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
╠═══════════════════════════════════════════════════════════════════════
╔═════════════════════════════════════════════════════════════════════╗
${header}
╠═════════════════════════════════════════════════════════════════════╣
║ ║
When accessing via browser, you'll be prompted to enter this key:
${line1}
║ ║
${API_KEY}
${line2}
║ ║
In Electron mode, authentication is handled automatically.
${line3}
║ ║
💡 Tip: Set AUTOMAKER_API_KEY env var to use a fixed key for dev
╚═══════════════════════════════════════════════════════════════════════╝
${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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -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<string, string | string[] | undefined>,
req.query as Record<string, string | undefined>,
@@ -411,5 +462,6 @@ export function checkRawAuthentication(
query: Record<string, string | undefined>,
cookies: Record<string, string | undefined>
): boolean {
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
return checkAuthentication(headers, query, cookies).authenticated;
}

View File

@@ -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<ActiveClaudeApiProfileResult> {
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<ProviderByIdResult> {
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<PhaseModelWithOverridesResult> {
// 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<ProviderByModelIdResult> {
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 [];
}
}

View File

@@ -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;

View File

@@ -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<string, string | undefined> {
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<string, string | undefined> {
const env: Record<string, string | undefined> = {};
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<string, string | undefined>;
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 });

View File

@@ -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();

View File

@@ -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',
};

View File

@@ -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<SimpleQu
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {
@@ -207,6 +225,9 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
reasoningEffort: options.reasoningEffort,
readOnly: options.readOnly,
settingSources: options.settingSources,
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
};
for await (const msg of provider.executeQuery(providerOptions)) {

View File

@@ -14,7 +14,11 @@ import { streamingQuery } from '../../providers/simple-query-service.js';
import { parseAndCreateFeatures } from './parse-and-create-features.js';
import { getAppSpecPath } from '@automaker/platform';
import type { SettingsService } from '../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
import {
getAutoLoadClaudeMdSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('SpecRegeneration');
@@ -115,13 +119,26 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
'[FeatureGeneration]'
);
// Get model from phase settings
const settings = await settingsService?.getGlobalSettings();
const phaseModelEntry =
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider,
credentials,
} = settingsService
? await getPhaseModelWithOverrides(
'featureGenerationModel',
settingsService,
projectPath,
'[FeatureGeneration]'
)
: {
phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
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');
// Use streamingQuery with event callbacks
const result = await streamingQuery({
@@ -134,6 +151,8 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
thinkingLevel,
readOnly: true, // Feature generation only reads code, doesn't write
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
onText: (text) => {
logger.debug(`Feature text block received (${text.length} chars)`);
events.emit('spec-regeneration:event', {

View File

@@ -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',

View File

@@ -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)}`);
},

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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');

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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 {

View File

@@ -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 = '';

View File

@@ -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',

View File

@@ -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);

View File

@@ -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;

View File

@@ -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`);

View File

@@ -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<void> {
return async (req: Request, res: Response): Promise<void> => {
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;

View File

@@ -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<void> {
return async (req: Request, res: Response): Promise<void> => {
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;

View File

@@ -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';

View File

@@ -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',

View File

@@ -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;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
`[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) {

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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}`);

View File

@@ -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

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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);

View File

@@ -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<string> {
let responseText = '';
for await (const msg of stream) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success') {
responseText = msg.result || responseText;
}
}
return responseText;
}
export function createGenerateCommitMessageHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
@@ -184,67 +157,68 @@ 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;
// 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}`);
const provider = ProviderFactory.getProviderForModel(model);
// Get provider for the model type
const aiProvider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
// 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;
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
let responseText = '';
const cursorStream = provider.executeQuery({
prompt: cursorPrompt,
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
});
// Wrap with timeout to prevent indefinite hangs
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
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');

View File

@@ -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', {

View File

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

View File

@@ -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<void> => {
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<string, string>();
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<string, RemoteBranch[]>();
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) });
}
};
}

View File

@@ -14,8 +14,13 @@ import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js';
import {
readAllWorktreeMetadata,
updateWorktreePRInfo,
type WorktreePRInfo,
} from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import {
checkGitHubRemote,
type GitHubRemoteStatus,
@@ -168,8 +173,11 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
}
/**
* Fetch open PRs from GitHub and create a map of branch name to PR info.
* This allows detecting PRs that were created outside the app.
* Fetch all PRs from GitHub and create a map of branch name to PR info.
* Uses --state all to include merged/closed PRs, allowing detection of
* state changes (e.g., when a PR is merged on GitHub).
*
* This also allows detecting PRs that were created outside the app.
*
* Uses cached GitHub remote status to avoid repeated warnings when the
* project doesn't have a GitHub remote configured.
@@ -192,9 +200,9 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
? `-R ${remoteStatus.owner}/${remoteStatus.repo}`
: '';
// Fetch open PRs from GitHub
// Fetch all PRs from GitHub (including merged/closed to detect state changes)
const { stdout } = await execAsync(
`gh pr list ${repoFlag} --state open --json number,title,url,state,headRefName,createdAt --limit 1000`,
`gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`,
{ cwd: projectPath, env: execEnv, timeout: 15000 }
);
@@ -212,7 +220,8 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
number: pr.number,
url: pr.url,
title: pr.title,
state: pr.state,
// GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED
state: validatePRState(pr.state),
createdAt: pr.createdAt,
});
}
@@ -351,23 +360,43 @@ export function createListHandler() {
}
}
// Add PR info from metadata or GitHub for each worktree
// Only fetch GitHub PRs if includeDetails is requested (performance optimization)
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
const githubPRs = includeDetails
? await fetchGitHubPRs(projectPath)
: new Map<string, WorktreePRInfo>();
for (const worktree of worktrees) {
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;
// 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);
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;
}
}

View File

@@ -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<void> => {
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}`}"`;
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
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Cleanup errors are non-fatal
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
res.json({ success: true, mergedBranch: branchName });
// 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,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
} catch (error) {
logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -0,0 +1,181 @@
/**
* Terminal endpoints for opening worktree directories in terminals
*
* POST /open-in-terminal - Open in system default terminal (integrated)
* GET /available-terminals - List all available external terminals
* GET /default-terminal - Get the default external terminal
* POST /refresh-terminals - Clear terminal cache and re-detect
* POST /open-in-external-terminal - Open a directory in an external terminal
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
openInTerminal,
clearTerminalCache,
detectAllTerminals,
detectDefaultTerminal,
openInExternalTerminal,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-terminal');
/**
* Handler to open in system default terminal (integrated terminal behavior)
*/
export function createOpenInTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
// Use the platform utility to open in terminal
const result = await openInTerminal(worktreePath);
res.json({
success: true,
result: {
message: `Opened terminal in ${worktreePath}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get all available external terminals
*/
export function createGetAvailableTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminals = await detectAllTerminals();
res.json({
success: true,
result: {
terminals,
},
});
} catch (error) {
logError(error, 'Get available terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to get the default external terminal
*/
export function createGetDefaultTerminalHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const terminal = await detectDefaultTerminal();
res.json({
success: true,
result: terminal
? {
terminalId: terminal.id,
terminalName: terminal.name,
terminalCommand: terminal.command,
}
: null,
});
} catch (error) {
logError(error, 'Get default terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to refresh the terminal cache and re-detect available terminals
* Useful when the user has installed/uninstalled terminals
*/
export function createRefreshTerminalsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearTerminalCache();
// Re-detect terminals (this will repopulate the cache)
const terminals = await detectAllTerminals();
logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`);
res.json({
success: true,
result: {
terminals,
message: `Found ${terminals.length} available external terminals`,
},
});
} catch (error) {
logError(error, 'Refresh terminals failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Handler to open a directory in an external terminal
*/
export function createOpenInExternalTerminalHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, terminalId } = req.body as {
worktreePath: string;
terminalId?: string;
};
if (!worktreePath || typeof worktreePath !== 'string') {
res.status(400).json({
success: false,
error: 'worktreePath required and must be a string',
});
return;
}
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const result = await openInExternalTerminal(worktreePath, terminalId);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.terminalName}`,
terminalName: result.terminalName,
},
});
} catch (error) {
logError(error, 'Open in external terminal failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
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) {

View File

@@ -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);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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];

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<GlobalSettings>): Partial<GlobalSettings> {
const migrated: Partial<GlobalSettings> = { ...settings };
// Migrate Cursor models
if (settings.enabledCursorModels) {
migrated.enabledCursorModels = migrateCursorModelIds(
settings.enabledCursorModels as string[]
);
}
// Migrate Cursor default model
if (settings.cursorDefaultModel) {
const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.cursorDefaultModel = migratedDefault[0];
}
}
// Migrate OpenCode models
if (settings.enabledOpencodeModels) {
migrated.enabledOpencodeModels = migrateOpencodeModelIds(
settings.enabledOpencodeModels as string[]
);
}
// Migrate OpenCode default model
if (settings.opencodeDefaultModel) {
const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]);
if (migratedDefault.length > 0) {
migrated.opencodeDefaultModel = migratedDefault[0];
}
}
return migrated;
}
/**
@@ -273,13 +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
) {
// 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
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
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
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.lastSelectedSessionByProject;
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<string, unknown>).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<void> {
await fs.mkdir(destDir, { recursive: true });
const entries = await fs.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(srcPath, destPath);
} else if (entry.isFile()) {
const content = await fs.readFile(srcPath);
await fs.writeFile(destPath, content);
}
}
}
}

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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(),
},
};

View File

@@ -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();

View File

@@ -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([]);
});
});
});
});

View File

@@ -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', () => {

View File

@@ -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 = {

View File

@@ -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',
});
});

View File

@@ -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"

View File

@@ -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<UsageError | null>(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() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>
@@ -258,28 +183,18 @@ export function ClaudeUsagePopover() {
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1 flex flex-col items-center">
<p className="text-sm font-medium">{error.message}</p>
<p className="text-sm font-medium">
{error instanceof Error ? error.message : 'Failed to fetch usage'}
</p>
<p className="text-xs text-muted-foreground">
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</>
)}
</p>
</div>
</div>
) : !claudeUsage ? (
// Loading state
) : isLoading || !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (

View File

@@ -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<UsageError | null>(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;
// 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 };
}
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,
});
if (message.includes('bridge') || message.includes('API')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
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);
}
}, [isStale, isCodexAuthenticated, fetchUsage]);
useEffect(() => {
// Skip if not authenticated
if (!isCodexAuthenticated) return;
// Initial fetch when opened
if (open) {
if (!codexUsage || 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, 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() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>
@@ -333,7 +274,7 @@ export function CodexUsagePopover() {
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -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 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
<Spinner size="lg" />
</div>
)}
</div>
@@ -353,7 +404,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
@@ -377,11 +428,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Opacity</Label>
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
</div>
<Slider
value={[cardOpacity]}
value={[localCardOpacity]}
onValueChange={handleCardOpacityChange}
onValueCommit={handleCardOpacityCommit}
min={0}
max={100}
step={1}
@@ -392,11 +444,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Column Opacity</Label>
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
</div>
<Slider
value={[columnOpacity]}
value={[localColumnOpacity]}
onValueChange={handleColumnOpacityChange}
onValueCommit={handleColumnOpacityCommit}
min={0}
max={100}
step={1}
@@ -445,11 +498,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Border Opacity</Label>
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
</div>
<Slider
value={[cardBorderOpacity]}
value={[localCardBorderOpacity]}
onValueChange={handleCardBorderOpacityChange}
onValueCommit={handleCardBorderOpacityCommit}
min={0}
max={100}
step={1}

View File

@@ -14,16 +14,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
FolderPlus,
FolderOpen,
Rocket,
ExternalLink,
Check,
Loader2,
Link,
Folder,
} from 'lucide-react';
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
@@ -451,7 +443,7 @@ export function NewProjectModal({
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
</>
) : (

View File

@@ -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<WorkspaceDirectory[]>([]);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
@@ -74,24 +48,24 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
{isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-sm text-muted-foreground">Loading projects...</p>
</div>
)}
{error && !isLoading && (
{errorMessage && !isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<p className="text-sm text-destructive">{error}</p>
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
<p className="text-sm text-destructive">{errorMessage}</p>
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
Try Again
</Button>
</div>
)}
{!isLoading && !error && directories.length === 0 && (
{!isLoading && !errorMessage && directories.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
<Folder className="w-6 h-6 text-muted-foreground" />
@@ -102,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
</div>
)}
{!isLoading && !error && directories.length > 0 && (
{!isLoading && !errorMessage && directories.length > 0 && (
<div className="space-y-2">
{directories.map((dir) => (
<button

View File

@@ -0,0 +1,213 @@
import type { ComponentType, ComponentProps } from 'react';
import { Terminal } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
/**
* iTerm2 logo icon
*/
export function ITerm2Icon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.586 0a2.56 2.56 0 00-2.56 2.56v18.88A2.56 2.56 0 002.586 24h18.88a2.56 2.56 0 002.56-2.56V2.56A2.56 2.56 0 0021.466 0H2.586zm8.143 4.027h2.543v15.946h-2.543V4.027zm-3.816 0h2.544v15.946H6.913V4.027zm7.633 0h2.543v15.946h-2.543V4.027z" />
</svg>
);
}
/**
* Warp terminal logo icon
*/
export function WarpIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M9.2 4.8L7.6 7.6 4.8 5.6l1.6-2.8L9.2 4.8zm5.6 0l1.6 2.8 2.8-2-1.6-2.8-2.8 2zM2.4 12l2.8 1.6L3.6 16 .8 14.4 2.4 12zm19.2 0l1.6 2.4-2.8 1.6-1.6-2.4 2.8-1.6zM7.6 16.4l1.6 2.8-2.8 2-1.6-2.8 2.8-2zm8.8 0l2.8 2-1.6 2.8-2.8-2 1.6-2.8zM12 0L8.4 2 12 4l3.6-2L12 0zm0 20l-3.6 2 3.6 2 3.6-2-3.6-2z" />
</svg>
);
}
/**
* Ghostty terminal logo icon
*/
export function GhosttyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-5.52-4.48-10-10-10zm-3.5 12a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM12 19c-1.5 0-3-.5-4-1.5v-1c2 1 6 1 8 0v1c-1 1-2.5 1.5-4 1.5z" />
</svg>
);
}
/**
* Alacritty terminal logo icon
*/
export function AlacrittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 0L1.608 21.6h3.186l1.46-3.032h11.489l1.46 3.032h3.189L12 0zm0 7.29l3.796 7.882H8.204L12 7.29z" />
</svg>
);
}
/**
* WezTerm terminal logo icon
*/
export function WezTermIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2 2h12v2H6V8zm0 4h8v2H6v-2z" />
</svg>
);
}
/**
* Kitty terminal logo icon
*/
export function KittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.5 7.5L1 5V2.5L3.5 5V7.5zM20.5 7.5L23 5V2.5L20.5 5V7.5zM12 4L6 8v8l6 4 6-4V8l-6-4zm0 2l4 2.67v5.33L12 16.67 8 14V8.67L12 6z" />
</svg>
);
}
/**
* Hyper terminal logo icon
*/
export function HyperIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M11.857 23.995v-7.125H6.486l5.765-10.856-.363-1.072L7.803.001 0 12.191h5.75L0 23.995h11.857zm.286 0h5.753l5.679-11.804h-5.679l5.679-11.804L17.896.388l-5.753 11.803h5.753L12.143 24z" />
</svg>
);
}
/**
* Tabby terminal logo icon
*/
export function TabbyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2l6 3v10l-6 3-6-3V7l6-3z" />
</svg>
);
}
/**
* Rio terminal logo icon
*/
export function RioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
</svg>
);
}
/**
* Windows Terminal logo icon
*/
export function WindowsTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.165 6L0 9.497v5.006L8.165 18l.413-.206v-4.025L3.197 12l5.381-1.769V6.206L8.165 6zm7.67 0l-.413.206v4.025L20.803 12l-5.381 1.769v4.025l.413.206L24 14.503V9.497L15.835 6z" />
</svg>
);
}
/**
* PowerShell logo icon
*/
export function PowerShellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035h18.23zM8.958 16.677c0 .334.276.611.611.611h3.673a.615.615 0 00.611-.611.615.615 0 00-.611-.611h-3.673a.615.615 0 00-.611.611zm5.126-7.016L9.025 14.72c-.241.241-.241.63 0 .872.241.241.63.241.872 0l5.059-5.059c.241-.241.241-.63 0-.872l-5.059-5.059c-.241-.241-.63-.241-.872 0-.241.241-.241.63 0 .872l5.059 5.059c-.334.334-.334.334 0 0z" />
</svg>
);
}
/**
* Command Prompt (cmd) logo icon
*/
export function CmdIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2.5 1.5l3 3-3 3L5 12l3-3zm5.5 5h6v1.5h-6V12z" />
</svg>
);
}
/**
* Git Bash logo icon
*/
export function GitBashIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
/**
* GNOME Terminal logo icon
*/
export function GnomeTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4a2 2 0 00-2 2v12a2 2 0 002 2h20a2 2 0 002-2V6a2 2 0 00-2-2H2zm0 2h20v12H2V6zm2 2v2h2V8H4zm4 0v2h12V8H8zm-4 4v2h2v-2H4zm4 0v2h8v-2H8z" />
</svg>
);
}
/**
* Konsole logo icon
*/
export function KonsoleIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 3h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5a2 2 0 012-2zm0 2v14h18V5H3zm2 2l4 4-4 4V7zm6 6h8v2h-8v-2z" />
</svg>
);
}
/**
* macOS Terminal logo icon
*/
export function MacOSTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 4a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2H3zm0 2h18v12H3V6zm2 2l5 4-5 4V8zm7 6h7v2h-7v-2z" />
</svg>
);
}
/**
* Get the appropriate icon component for a terminal ID
*/
export function getTerminalIcon(terminalId: string): IconComponent {
const terminalIcons: Record<string, IconComponent> = {
iterm2: ITerm2Icon,
warp: WarpIcon,
ghostty: GhosttyIcon,
alacritty: AlacrittyIcon,
wezterm: WezTermIcon,
kitty: KittyIcon,
hyper: HyperIcon,
tabby: TabbyIcon,
rio: RioIcon,
'windows-terminal': WindowsTerminalIcon,
powershell: PowerShellIcon,
cmd: CmdIcon,
'git-bash': GitBashIcon,
'gnome-terminal': GnomeTerminalIcon,
konsole: KonsoleIcon,
'terminal-macos': MacOSTerminalIcon,
// Linux terminals - use generic terminal icon
'xfce4-terminal': Terminal,
tilix: Terminal,
terminator: Terminal,
foot: Terminal,
xterm: Terminal,
};
return terminalIcons[terminalId] ?? Terminal;
}

View File

@@ -456,7 +456,7 @@ export function ProjectContextMenu({
<div
className="flex gap-2 overflow-y-auto scrollbar-styled"
style={{
maxHeight: `${submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT}px`,
maxHeight: `${Math.max(0, submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT)}px`,
}}
>
<ThemeColumn

View File

@@ -1,6 +1,6 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn, sanitizeForTestId } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
@@ -37,10 +37,15 @@ export function ProjectSwitcherItem({
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
const testId = `project-switcher-${project.id}-${sanitizeForTestId(project.name)}`;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
data-testid={testId}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
@@ -60,7 +65,6 @@ export function ProjectSwitcherItem({
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
@@ -10,7 +10,7 @@ import { EditProjectDialog } from './components/edit-project-dialog';
import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
@@ -41,7 +41,6 @@ export function ProjectSwitcher() {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
specCreatingForProject,
setSpecCreatingForProject,
@@ -69,9 +68,6 @@ export function ProjectSwitcher() {
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
// Get global theme for project creation
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
@@ -84,9 +80,6 @@ export function ProjectSwitcher() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -161,13 +154,8 @@ export function ProjectSwitcher() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -198,7 +186,7 @@ export function ProjectSwitcher() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
}, [upsertAndSetCurrentProject, navigate]);
// Handler for creating initial spec from the setup dialog
const handleCreateInitialSpec = useCallback(async () => {

View File

@@ -4,7 +4,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
@@ -34,7 +34,6 @@ import {
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
@@ -79,9 +78,6 @@ export function Sidebar() {
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
@@ -97,9 +93,6 @@ export function Sidebar() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -198,13 +191,8 @@ export function Sidebar() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -232,7 +220,7 @@ export function Sidebar() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
}, [upsertAndSetCurrentProject]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({

View File

@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -93,9 +93,10 @@ export function SidebarNavigation({
>
<div className="relative">
{item.isLoading ? (
<Loader2
<Spinner
size="md"
className={cn(
'w-[18px] h-[18px] shrink-0 animate-spin',
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>

View File

@@ -6,22 +6,13 @@ const logger = createLogger('ProjectCreation');
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
import type { ThemeMode } from '@/store/app-store';
import type { TrashedProject, Project } from '@/lib/electron';
import type { Project } from '@/lib/electron';
interface UseProjectCreationProps {
trashedProjects: TrashedProject[];
currentProject: Project | null;
globalTheme: ThemeMode;
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
upsertAndSetCurrentProject: (path: string, name: string) => Project;
}
export function useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
export function useProjectCreation({ upsertAndSetCurrentProject }: UseProjectCreationProps) {
// Modal state
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
@@ -67,14 +58,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme: try trashed project theme, then current project theme, then global
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
@@ -92,7 +77,7 @@ export function useProjectCreation({
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -169,14 +154,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -194,7 +173,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -244,14 +223,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -269,7 +242,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
return {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager');
@@ -16,12 +17,14 @@ import {
Check,
X,
ArchiveRestore,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { useSessions } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
@@ -102,7 +105,7 @@ export function SessionManager({
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
@@ -113,8 +116,14 @@ export function SessionManager({
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Use React Query for sessions list - always include archived, filter client-side
const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
// Ref to track if we've done the initial running sessions check
const hasCheckedInitialRef = useRef(false);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
const api = getElectronAPI();
if (!api?.agent) return;
@@ -134,26 +143,26 @@ export function SessionManager({
}
setRunningSessions(runningIds);
};
// Load sessions
const loadSessions = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
// Always load all sessions and filter client-side
const result = await api.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
// Check running state for all sessions
await checkRunningSessions(result.sessions);
}
};
useEffect(() => {
loadSessions();
}, []);
// Helper to invalidate sessions cache and refetch
const invalidateSessions = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
// Also check running state after invalidation
const result = await refetchSessions();
if (result.data) {
await checkRunningSessions(result.data);
}
}, [queryClient, refetchSessions, checkRunningSessions]);
// Check running state on initial load (runs only once when sessions first load)
useEffect(() => {
if (sessions.length > 0 && !hasCheckedInitialRef.current) {
hasCheckedInitialRef.current = true;
checkRunningSessions(sessions);
}
}, [sessions, checkRunningSessions]);
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
@@ -166,7 +175,7 @@ export function SessionManager({
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
// Create new session with random name
const handleCreateSession = async () => {
@@ -180,7 +189,7 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -195,7 +204,7 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -222,7 +231,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName('');
await loadSessions();
await invalidateSessions();
}
};
@@ -241,7 +250,7 @@ export function SessionManager({
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Archive failed:', result.error);
}
@@ -261,7 +270,7 @@ export function SessionManager({
try {
const result = await api.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Unarchive failed:', result.error);
}
@@ -283,7 +292,7 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
@@ -305,7 +314,7 @@ export function SessionManager({
await api.sessions.delete(session.id);
}
await loadSessions();
await invalidateSessions();
setIsDeleteAllArchivedDialogOpen(false);
};
@@ -466,7 +475,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
<Spinner size="sm" className="shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -39,7 +39,7 @@ const buttonVariants = cva(
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
return <Spinner size="sm" className={className} />;
}
function Button({

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { ImageIcon, X, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
@@ -431,7 +432,7 @@ export function DescriptionImageDropZone({
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
<span>Processing files...</span>
</div>
)}

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
fileToBase64,
generateImageId,
@@ -196,7 +197,7 @@ export function FeatureImageUpload({
)}
>
{isProcessing ? (
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
<Spinner size="md" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
File,
@@ -9,12 +8,13 @@ import {
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
@@ -350,56 +350,44 @@ export function GitDiffPanel({
useWorktrees = false,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>('');
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
// Use worktree diffs hook when worktrees are enabled and panel is expanded
// Pass undefined for featureId when not using worktrees to disable the query
const {
data: worktreeDiffsData,
isLoading: isLoadingWorktree,
error: worktreeError,
refetch: refetchWorktree,
} = useWorktreeDiffs(
useWorktrees && isExpanded ? projectPath : undefined,
useWorktrees && isExpanded ? featureId : undefined
);
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error('Worktree API not available');
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || '');
} else {
setError(result.error || 'Failed to load diffs');
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error('Git API not available');
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || '');
} else {
setError(result.error || 'Failed to load diffs');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load diffs');
} finally {
setIsLoading(false);
}
}, [projectPath, featureId, useWorktrees]);
// Use git diffs hook when worktrees are disabled and panel is expanded
const {
data: gitDiffsData,
isLoading: isLoadingGit,
error: gitError,
refetch: refetchGit,
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
// Select the appropriate data based on useWorktrees prop
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
const queryError = useWorktrees ? worktreeError : gitError;
// Extract files and diff content from the data
const files: FileStatus[] = diffsData?.files ?? [];
const diffContent = diffsData?.diff ?? '';
const error = queryError
? queryError instanceof Error
? queryError.message
: 'Failed to load diffs'
: null;
// Refetch function
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
@@ -484,7 +472,7 @@ export function GitDiffPanel({
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<Spinner size="md" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
@@ -204,7 +205,7 @@ export function ImageDropZone({
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -1,17 +1,15 @@
import { Loader2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
/** Optional custom size class for the spinner (default: h-8 w-8) */
size?: string;
}
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
export function LoadingState({ message }: LoadingStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
<Spinner size="xl" />
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
</div>
);
}

View File

@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -148,7 +148,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case 'in_progress':
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
return <Spinner size="sm" />;
case 'pending':
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
default:

View File

@@ -523,6 +523,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
}
}
// Check for ClaudeCompatibleProvider model patterns (GLM, MiniMax, etc.)
// These are model IDs like "GLM-4.5-Air", "GLM-4.7", "MiniMax-M2.1"
if (modelStr.includes('glm')) {
return 'glm';
}
if (modelStr.includes('minimax')) {
return 'minimax';
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -536,7 +545,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (modelStr.includes('grok')) {
return 'grok';
}
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
// Cursor models - canonical format includes 'cursor-' prefix
// Also support legacy IDs for backward compatibility
if (
modelStr.includes('cursor') ||
modelStr === 'auto' ||
modelStr === 'composer-1' ||
modelStr === 'cursor-auto' ||
modelStr === 'cursor-composer-1'
) {
return 'cursor';
}

View File

@@ -0,0 +1,18 @@
/**
* Skeleton Components
*
* Loading placeholder components for content that's being fetched.
*/
import { cn } from '@/lib/utils';
interface SkeletonPulseProps {
className?: string;
}
/**
* Pulsing skeleton placeholder for loading states
*/
export function SkeletonPulse({ className }: SkeletonPulseProps) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}

View File

@@ -0,0 +1,32 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Additional class names */
className?: string;
}
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
aria-hidden="true"
/>
);
}

View File

@@ -5,7 +5,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
@@ -260,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isActive && <Spinner size="xs" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '@/store/app-store';
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
import { getTerminalTheme, getTerminalFontFamily } from '@/config/terminal-themes';
// Types for dynamically imported xterm modules
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
@@ -20,7 +20,7 @@ export interface XtermLogViewerRef {
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
/** Font size in pixels (default: 13) */
/** Font size in pixels (uses terminal settings if not provided) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
@@ -42,7 +42,7 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
(
{
initialContent,
fontSize = 13,
fontSize,
autoScroll = true,
className,
minHeight = 300,
@@ -58,9 +58,14 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef<string[]>([]);
// Get theme from store
// Get theme and font settings from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
const terminalFontFamily = useAppStore((state) => state.terminalState.fontFamily);
const terminalFontSize = useAppStore((state) => state.terminalState.defaultFontSize);
// Use prop if provided, otherwise use store value, fallback to 13
const effectiveFontSize = fontSize ?? terminalFontSize ?? 13;
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
@@ -102,12 +107,17 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const terminalTheme = getTerminalTheme(resolvedTheme);
// Get font settings from store at initialization time
const terminalState = useAppStore.getState().terminalState;
const fontFamily = getTerminalFontFamily(terminalState.fontFamily);
const initFontSize = fontSize ?? terminalState.defaultFontSize ?? 13;
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
fontSize,
fontFamily: DEFAULT_TERMINAL_FONT,
fontSize: initFontSize,
fontFamily,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
@@ -181,10 +191,18 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontSize = fontSize;
xtermRef.current.options.fontSize = effectiveFontSize;
fitAddonRef.current?.fit();
}
}, [fontSize, isReady]);
}, [effectiveFontSize, isReady]);
// Update font family when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontFamily = getTerminalFontFamily(terminalFontFamily);
fitAddonRef.current?.fit();
}
}, [terminalFontFamily, isReady]);
// Handle resize
useEffect(() => {

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -60,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
}
export function UsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
const [claudeLoading, setClaudeLoading] = useState(false);
const [codexLoading, setCodexLoading] = useState(false);
const [claudeError, setClaudeError] = useState<UsageError | null>(null);
const [codexError, setCodexError] = useState<UsageError | null>(null);
// Check authentication status
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Use React Query hooks for usage data
// Only enable polling when popover is open AND the tab is active
const {
data: claudeUsage,
isLoading: claudeLoading,
error: claudeQueryError,
dataUpdatedAt: claudeUsageLastUpdated,
refetch: refetchClaude,
} = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated);
const {
data: codexUsage,
isLoading: codexLoading,
error: codexQueryError,
dataUpdatedAt: codexUsageLastUpdated,
refetch: refetchCodex,
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
// Parse errors into structured format
const claudeError = useMemo((): UsageError | null => {
if (!claudeQueryError) return null;
const message =
claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError);
// Detect trust prompt error
const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission');
if (isTrustPrompt) {
return { code: ERROR_CODES.TRUST_PROMPT, message };
}
if (message.includes('API bridge')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [claudeQueryError]);
const codexError = useMemo((): UsageError | null => {
if (!codexQueryError) return null;
const message =
codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError);
if (message.includes('not available') || message.includes('does not provide')) {
return { code: ERROR_CODES.NOT_AVAILABLE, message };
}
if (message.includes('API bridge')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [codexQueryError]);
// Determine which tab to show by default
useEffect(() => {
if (isClaudeAuthenticated) {
@@ -94,137 +135,9 @@ export function UsagePopover() {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
const fetchClaudeUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setClaudeLoading(true);
setClaudeError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setClaudeError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Claude API bridge not available',
});
return;
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setClaudeError({
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
}
setClaudeUsage(data);
} catch (err) {
setClaudeError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setClaudeLoading(false);
}
},
[setClaudeUsage]
);
const fetchCodexUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setCodexLoading(true);
setCodexError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setCodexError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Codex API bridge not available',
});
return;
}
const data = await api.codex.getUsage();
if ('error' in data) {
if (
data.message?.includes('not available') ||
data.message?.includes('does not provide')
) {
setCodexError({
code: ERROR_CODES.NOT_AVAILABLE,
message: data.message || data.error,
});
} else {
setCodexError({
code: ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
}
return;
}
setCodexUsage(data);
} catch (err) {
setCodexError({
code: ERROR_CODES.UNKNOWN,
message: err instanceof Error ? err.message : 'Failed to fetch usage',
});
} finally {
if (!isAutoRefresh) setCodexLoading(false);
}
},
[setCodexUsage]
);
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isClaudeStale && isClaudeAuthenticated) {
fetchClaudeUsage(true);
}
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
useEffect(() => {
if (isCodexStale && isCodexAuthenticated) {
fetchCodexUsage(true);
}
}, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]);
// Auto-refresh when popover is open
useEffect(() => {
if (!open) return;
// Fetch based on active tab
if (activeTab === 'claude' && isClaudeAuthenticated) {
if (!claudeUsage || isClaudeStale) {
fetchClaudeUsage();
}
const intervalId = setInterval(() => {
fetchClaudeUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
return () => clearInterval(intervalId);
}
if (activeTab === 'codex' && isCodexAuthenticated) {
if (!codexUsage || isCodexStale) {
fetchCodexUsage();
}
const intervalId = setInterval(() => {
fetchCodexUsage(true);
}, REFRESH_INTERVAL_SECONDS * 1000);
return () => clearInterval(intervalId);
}
}, [
open,
activeTab,
claudeUsage,
isClaudeStale,
isClaudeAuthenticated,
codexUsage,
isCodexStale,
isCodexAuthenticated,
fetchClaudeUsage,
fetchCodexUsage,
]);
// Refetch functions for manual refresh
const fetchClaudeUsage = () => refetchClaude();
const fetchCodexUsage = () => refetchCodex();
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -416,7 +329,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
onClick={() => !claudeLoading && fetchClaudeUsage()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -449,7 +362,7 @@ export function UsagePopover() {
</div>
) : !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
@@ -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()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -568,7 +481,7 @@ export function UsagePopover() {
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -11,12 +11,12 @@ import {
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
@@ -236,7 +236,7 @@ export function AgentToolsView() {
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Reading...
</>
) : (
@@ -315,7 +315,7 @@ export function AgentToolsView() {
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Writing...
</>
) : (
@@ -383,7 +383,7 @@ export function AgentToolsView() {
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Running...
</>
) : (

View File

@@ -42,7 +42,7 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@@ -1,4 +1,5 @@
import { Bot } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
export function ThinkingIndicator() {
return (
@@ -8,20 +9,7 @@ export function ThinkingIndicator() {
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<Spinner size="sm" />
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>

View File

@@ -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<string | null>(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)
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Analyzing...
</>
) : (
@@ -771,7 +779,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<Spinner size="xl" className="mb-4" />
<p className="text-muted-foreground">Scanning project files...</p>
</div>
) : projectAnalysis ? (
@@ -850,7 +858,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
@@ -903,7 +911,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -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<ReturnType<typeof useAppStore.getState>['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<typeof handleAddFeature>[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<Set<string>>(new Set());
// Listen to auto mode events to remove features from pending when they start running
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
}
});
return unsubscribe;
}, [currentProject]);
// NOTE: Auto mode polling loop has been moved to the backend.
// The frontend now just toggles the backend's auto loop via API calls.
// See use-auto-mode.ts for the start/stop logic that calls the backend.
// Listen for backlog plan events (for background generation)
useEffect(() => {
@@ -976,219 +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<string, typeof hookFeatures> = {};
@@ -1384,7 +1258,7 @@ export function BoardView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}
@@ -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,6 +1313,13 @@ export function BoardView() {
onViewModeChange={setViewMode}
/>
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
@@ -1445,9 +1344,20 @@ export function BoardView() {
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onMerge={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowMergeWorktreeDialog(true);
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}
@@ -1503,10 +1413,6 @@ export function BoardView() {
/>
) : (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
@@ -1548,6 +1454,7 @@ export function BoardView() {
/>
)}
</div>
</DndContext>
{/* Selection Action Bar */}
{isSelectionMode && (
@@ -1641,6 +1548,15 @@ export function BoardView() {
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Dependency Link Dialog */}
<DependencyLinkDialog
open={Boolean(pendingDependencyLink)}
onOpenChange={(open) => !open && clearPendingDependencyLink()}
draggedFeature={pendingDependencyLink?.draggedFeature || null}
targetFeature={pendingDependencyLink?.targetFeature || null}
onLink={handleCreateDependencyLink}
/>
{/* Edit Feature Dialog */}
<EditFeatureDialog
feature={editingFeature}
@@ -1662,6 +1578,7 @@ export function BoardView() {
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
branchName={outputFeature?.branchName}
/>
{/* 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 */}
<MergeWorktreeDialog
open={showMergeWorktreeDialog}
onOpenChange={setShowMergeWorktreeDialog}
projectPath={currentProject.path}
{/* Pull & Resolve Conflicts Dialog */}
<PullResolveConflictsDialog
open={showPullResolveConflictsDialog}
onOpenChange={setShowPullResolveConflictsDialog}
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter((f) => 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 */}

View File

@@ -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
</Label>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
import { Search, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface BoardSearchBarProps {
searchQuery: string;
@@ -75,7 +76,7 @@ export function BoardSearchBar({
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<Spinner size="xs" className="shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>

View File

@@ -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<AgentTaskInfo | null>(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<string, 'pending' | 'in_progress' | 'completed'>
>(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' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
@@ -448,4 +405,4 @@ export function AgentInfoPanel({
onOpenChange={setIsSummaryDialogOpen}
/>
);
}
});

View File

@@ -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({
)}
</div>
);
}
});

View File

@@ -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) {
</TooltipProvider>
</div>
);
}
});
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) {
)}
</div>
);
}
});

View File

@@ -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
})()}
</>
);
}
});

Some files were not shown because too many files have changed in this diff Show More