mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
3 Commits
v0.13.0
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88c940a36 | ||
|
|
10b49bd3b4 | ||
|
|
53298106e9 |
300
SECURITY_TODO.md
300
SECURITY_TODO.md
@@ -1,300 +0,0 @@
|
||||
# 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
|
||||
8
TODO.md
8
TODO.md
@@ -2,14 +2,6 @@
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -249,7 +249,7 @@ notificationService.setEventEmitter(events);
|
||||
const eventHistoryService = getEventHistoryService();
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
|
||||
@@ -23,13 +23,6 @@ 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 }>();
|
||||
|
||||
@@ -141,8 +134,8 @@ const API_KEY = ensureApiKey();
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
|
||||
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
|
||||
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
||||
|
||||
// Build box lines with exact padding
|
||||
@@ -382,12 +375,6 @@ function checkAuthentication(
|
||||
* 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>,
|
||||
@@ -433,10 +420,9 @@ 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: !disabled,
|
||||
method: disabled ? 'disabled' : 'api_key_or_session',
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -444,7 +430,6 @@ 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>,
|
||||
@@ -462,6 +447,5 @@ 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;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,7 @@ import type {
|
||||
McpServerConfig,
|
||||
PromptCustomization,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
@@ -365,22 +360,16 @@ export interface ActiveClaudeApiProfileResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude API profile and credentials from settings.
|
||||
* Checks project settings first for per-project overrides, then falls back to global settings.
|
||||
* Get the active Claude API profile and credentials from 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
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ActiveClaudeApiProfileResult> {
|
||||
if (!settingsService) {
|
||||
return { profile: undefined, credentials: undefined };
|
||||
@@ -390,30 +379,10 @@ export async function getActiveClaudeApiProfile(
|
||||
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;
|
||||
}
|
||||
const 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 };
|
||||
}
|
||||
|
||||
@@ -421,8 +390,7 @@ export async function getActiveClaudeApiProfile(
|
||||
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}`);
|
||||
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}`);
|
||||
return { profile: activeProfile, credentials };
|
||||
} else {
|
||||
logger.warn(
|
||||
@@ -435,296 +403,3 @@ export async function getActiveClaudeApiProfile(
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,8 @@ 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,
|
||||
@@ -60,48 +51,34 @@ const ALLOWED_ENV_VARS = [
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
|
||||
/**
|
||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||
* by checking for the 'models' array property
|
||||
*/
|
||||
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.
|
||||
* When a profile is provided, uses profile configuration (clean switch - don't inherit from process.env).
|
||||
* When no profile 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 profile - Optional Claude API profile for alternative endpoint configuration
|
||||
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
|
||||
*/
|
||||
function buildEnv(
|
||||
providerConfig?: ProviderConfig,
|
||||
profile?: ClaudeApiProfile,
|
||||
credentials?: Credentials
|
||||
): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
|
||||
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),
|
||||
if (profile) {
|
||||
// Use profile configuration (clean switch - don't inherit non-system vars from process.env)
|
||||
logger.debug('Building environment from Claude API profile:', {
|
||||
name: profile.name,
|
||||
apiKeySource: profile.apiKeySource ?? 'inline',
|
||||
});
|
||||
|
||||
// Resolve API key based on source strategy
|
||||
let apiKey: string | undefined;
|
||||
const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
|
||||
const source = profile.apiKeySource ?? 'inline'; // Default to inline for backwards compat
|
||||
|
||||
switch (source) {
|
||||
case 'inline':
|
||||
apiKey = providerConfig.apiKey;
|
||||
apiKey = profile.apiKey;
|
||||
break;
|
||||
case 'env':
|
||||
apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
@@ -113,55 +90,46 @@ function buildEnv(
|
||||
|
||||
// Warn if no API key found
|
||||
if (!apiKey) {
|
||||
logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
|
||||
logger.warn(`No API key found for profile "${profile.name}" with source "${source}"`);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (providerConfig.useAuthToken) {
|
||||
if (profile.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}`);
|
||||
env['ANTHROPIC_BASE_URL'] = profile.baseUrl;
|
||||
|
||||
if (providerConfig.timeoutMs) {
|
||||
env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
|
||||
if (profile.timeoutMs) {
|
||||
env['API_TIMEOUT_MS'] = String(profile.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;
|
||||
}
|
||||
// Model mappings
|
||||
if (profile.modelMappings?.haiku) {
|
||||
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = profile.modelMappings.haiku;
|
||||
}
|
||||
if (profile.modelMappings?.sonnet) {
|
||||
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = profile.modelMappings.sonnet;
|
||||
}
|
||||
if (profile.modelMappings?.opus) {
|
||||
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = profile.modelMappings.opus;
|
||||
}
|
||||
|
||||
// Traffic control
|
||||
if (providerConfig.disableNonessentialTraffic) {
|
||||
if (profile.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
|
||||
// Use direct Anthropic API - two modes:
|
||||
// 1. API Key mode: ANTHROPIC_API_KEY from credentials/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) {
|
||||
// IMPORTANT: Do NOT set any profile vars (base URL, model mappings, etc.)
|
||||
// This ensures clean switching - only pass through what's in process.env
|
||||
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
|
||||
@@ -170,10 +138,9 @@ function buildEnv(
|
||||
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;
|
||||
}
|
||||
// Do NOT set ANTHROPIC_BASE_URL - let SDK use default Anthropic endpoint
|
||||
// Do NOT set model mappings - use standard Claude model names
|
||||
// Do NOT set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
}
|
||||
|
||||
// Always add system vars from process.env
|
||||
@@ -211,14 +178,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
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);
|
||||
|
||||
@@ -229,9 +191,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
// 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),
|
||||
// When a profile is active, uses profile settings (clean switch)
|
||||
// When no profile, uses direct Anthropic API (from process.env or CLI OAuth)
|
||||
env: buildEnv(claudeApiProfile, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
@@ -276,18 +238,6 @@ 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 });
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
InstallationStatus,
|
||||
ContentBlock,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -327,18 +328,10 @@ export class OpencodeProvider extends CliProvider {
|
||||
args.push('--format', 'json');
|
||||
|
||||
// Handle model selection
|
||||
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
||||
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||
if (options.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);
|
||||
const model = stripProviderPrefix(options.model);
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// Note: OpenCode reads from stdin automatically when input is piped
|
||||
@@ -1042,7 +1035,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
'lm studio': 'lmstudio',
|
||||
lmstudio: 'lmstudio',
|
||||
opencode: 'opencode',
|
||||
'z.ai coding plan': 'zai-coding-plan',
|
||||
'z.ai coding plan': 'z-ai',
|
||||
'z.ai': 'z-ai',
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
@@ -57,17 +56,9 @@ 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
|
||||
*/
|
||||
/** Active Claude API profile for alternative endpoint configuration */
|
||||
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 for resolving 'credentials' apiKeySource in Claude API profiles */
|
||||
credentials?: Credentials;
|
||||
}
|
||||
|
||||
@@ -140,8 +131,7 @@ 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)
|
||||
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
@@ -225,8 +215,7 @@ 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)
|
||||
claudeApiProfile: options.claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
@@ -119,26 +119,19 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
// 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,
|
||||
};
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
logger.info('Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
@@ -151,7 +144,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
@@ -96,26 +96,19 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
// 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,
|
||||
};
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
logger.info('Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
@@ -149,7 +142,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import {
|
||||
@@ -155,26 +155,16 @@ export async function syncSpec(
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
// 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 settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
@@ -204,7 +194,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
|
||||
@@ -26,24 +26,6 @@ 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
|
||||
|
||||
@@ -12,9 +12,8 @@ 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 {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
@@ -26,38 +25,26 @@ export function createStartHandler(autoModeService: AutoModeService) {
|
||||
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)) {
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode is already running for ${worktreeDesc}`,
|
||||
message: 'Auto mode is already running for this project',
|
||||
alreadyRunning: true,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the auto loop for this project/worktree
|
||||
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName,
|
||||
maxConcurrency
|
||||
);
|
||||
// Start the auto loop for this project
|
||||
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
|
||||
|
||||
logger.info(
|
||||
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
|
||||
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||
branchName: normalizedBranchName,
|
||||
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Start auto mode failed');
|
||||
|
||||
@@ -12,19 +12,11 @@ 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;
|
||||
};
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
|
||||
// If projectPath is provided, return per-project/worktree status
|
||||
// If projectPath is provided, return per-project status
|
||||
if (projectPath) {
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
const projectStatus = autoModeService.getStatusForProject(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: projectStatus.runningCount > 0,
|
||||
@@ -33,7 +25,6 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
runningCount: projectStatus.runningCount,
|
||||
maxConcurrency: projectStatus.maxConcurrency,
|
||||
projectPath,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -41,12 +32,10 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
// 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');
|
||||
|
||||
@@ -12,9 +12,8 @@ const logger = createLogger('AutoMode');
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
const { projectPath } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -25,38 +24,27 @@ export function createStopHandler(autoModeService: AutoModeService) {
|
||||
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)) {
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode is not running for ${worktreeDesc}`,
|
||||
message: 'Auto mode is not running for this project',
|
||||
wasRunning: false,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the auto loop for this project/worktree
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
// Stop the auto loop for this project
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
|
||||
|
||||
logger.info(
|
||||
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
|
||||
`Stopped auto loop for 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');
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
@@ -121,39 +121,18 @@ export async function generateBacklogPlan(
|
||||
content: 'Generating plan with AI...',
|
||||
});
|
||||
|
||||
// Get the model to use from settings or provided override with provider info
|
||||
// Get the model to use from settings or provided override
|
||||
let effectiveModel = model;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
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);
|
||||
if (!effectiveModel) {
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
logger.info(
|
||||
'[BacklogPlan] Using model:',
|
||||
effectiveModel,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
@@ -186,6 +165,12 @@ ${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
}
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
prompt: finalPrompt,
|
||||
@@ -198,7 +183,7 @@ ${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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
|
||||
@@ -85,9 +85,8 @@ export function createApplyHandler() {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
// Create the new feature - use the AI-generated ID if provided
|
||||
// Create the new feature
|
||||
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',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
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';
|
||||
@@ -21,7 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
@@ -155,22 +156,20 @@ ${contentToAnalyze}`;
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'fileDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[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));
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(
|
||||
`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
@@ -183,7 +182,7 @@ ${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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { isCursorModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, 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,7 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
@@ -274,27 +274,23 @@ export function createDescribeImageHandler(
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'imageDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Using model: ${model}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
logger.info(`[${requestId}] Using model: ${model}`);
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Build the instruction text from centralized prompts
|
||||
const instructionText = prompts.contextDescription.describeImagePrompt;
|
||||
|
||||
@@ -336,7 +332,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ 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, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
@@ -33,8 +36,6 @@ interface EnhanceRequestBody {
|
||||
model?: string;
|
||||
/** Optional thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Optional project path for per-project Claude API profile */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +65,7 @@ export function createEnhanceHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
|
||||
const { originalText, enhancementMode, model, thinkingLevel } =
|
||||
req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
@@ -123,35 +124,17 @@ export function createEnhanceHandler(
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
|
||||
// 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);
|
||||
// Resolve the model - use the passed model, default to sonnet for quality
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[EnhancePrompt]'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
@@ -163,8 +146,8 @@ export function createEnhanceHandler(
|
||||
allowedTools: [],
|
||||
thinkingLevel,
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
});
|
||||
|
||||
const enhancedText = result.text;
|
||||
|
||||
@@ -10,13 +10,15 @@ import { createLogger } from '@automaker/utils';
|
||||
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||
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,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateTitle');
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
@@ -34,7 +36,7 @@ export function createGenerateTitleHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
@@ -61,8 +63,11 @@ 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();
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[GenerateTitle]'
|
||||
);
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
@@ -73,6 +78,7 @@ export function createGenerateTitleHandler(
|
||||
cwd: process.cwd(),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getProviderByModelId,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
@@ -167,33 +167,18 @@ ${basePrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
logger.info(`Using model: ${model}`);
|
||||
|
||||
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}`);
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[IssueValidation]'
|
||||
);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model: effectiveModel,
|
||||
model: model as string,
|
||||
cwd: projectPath,
|
||||
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
|
||||
abortController,
|
||||
@@ -201,7 +186,7 @@ ${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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
|
||||
@@ -18,8 +18,7 @@ import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getProviderByModelId,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
@@ -172,12 +171,11 @@ ${prompts.suggestions.baseTemplate}`;
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
// Get model from phase settings with provider info (AI Suggestions = suggestionsModel)
|
||||
// Get model from phase settings (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
|
||||
@@ -187,46 +185,21 @@ ${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 {
|
||||
// Fallback to defaults
|
||||
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.suggestionsModel);
|
||||
// Use settings-based model
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'[Suggestions] Using model:',
|
||||
model,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
logger.info('[Suggestions] Using model:', model);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
@@ -260,7 +233,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
|
||||
@@ -48,8 +48,6 @@ 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(
|
||||
@@ -150,21 +148,5 @@ 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;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ export function createDiffsHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
// 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);
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
try {
|
||||
// Check if worktree exists
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -37,10 +37,7 @@ export function createFileDiffHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
// 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);
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
|
||||
@@ -11,13 +11,13 @@ import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, 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';
|
||||
import { getActiveClaudeApiProfile } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateCommitMessage');
|
||||
const execAsync = promisify(exec);
|
||||
@@ -157,29 +157,25 @@ export function createGenerateCommitMessageHandler(
|
||||
|
||||
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||
|
||||
// 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);
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
|
||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(
|
||||
`Using model for commit message: ${model}`,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
logger.info(`Using model for commit message: ${model}`);
|
||||
|
||||
// Get the effective system prompt (custom or default)
|
||||
const systemPrompt = await getSystemPrompt(settingsService);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
settingsService,
|
||||
'[GenerateCommitMessage]'
|
||||
);
|
||||
|
||||
// Get provider for the model type
|
||||
const aiProvider = ProviderFactory.getProviderForModel(model);
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
|
||||
@@ -188,10 +184,10 @@ export function createGenerateCommitMessageHandler(
|
||||
: userPrompt;
|
||||
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
|
||||
|
||||
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
|
||||
logger.info(`Using ${provider.getName()} provider for model: ${model}`);
|
||||
|
||||
let responseText = '';
|
||||
const stream = aiProvider.executeQuery({
|
||||
const stream = provider.executeQuery({
|
||||
prompt: effectivePrompt,
|
||||
model: bareModel,
|
||||
cwd: worktreePath,
|
||||
@@ -199,8 +195,7 @@ export function createGenerateCommitMessageHandler(
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
thinkingLevel, // Pass thinking level for extended thinking support
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
|
||||
@@ -28,10 +28,7 @@ export function createInfoHandler() {
|
||||
}
|
||||
|
||||
// Check if worktree exists (git worktrees are stored in project directory)
|
||||
// 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);
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
|
||||
@@ -110,10 +110,9 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch and check if remote branch exists
|
||||
// Get ahead/behind count for current branch
|
||||
let aheadCount = 0;
|
||||
let behindCount = 0;
|
||||
let hasRemoteBranch = false;
|
||||
try {
|
||||
// First check if there's a remote tracking branch
|
||||
const { stdout: upstreamOutput } = await execAsync(
|
||||
@@ -122,7 +121,6 @@ export function createListBranchesHandler() {
|
||||
);
|
||||
|
||||
if (upstreamOutput.trim()) {
|
||||
hasRemoteBranch = true;
|
||||
const { stdout: aheadBehindOutput } = await execAsync(
|
||||
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
|
||||
{ cwd: worktreePath }
|
||||
@@ -132,18 +130,7 @@ export function createListBranchesHandler() {
|
||||
behindCount = behind || 0;
|
||||
}
|
||||
} catch {
|
||||
// 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;
|
||||
}
|
||||
// No upstream branch set, that's okay
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -153,7 +140,6 @@ export function createListBranchesHandler() {
|
||||
branches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
|
||||
*
|
||||
* Allows merging a worktree branch into any target branch (defaults to 'main').
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidProject middleware in index.ts
|
||||
@@ -10,21 +8,18 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createMergeHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
|
||||
const { projectPath, branchName, worktreePath, options } = req.body as {
|
||||
projectPath: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
||||
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
|
||||
options?: { squash?: boolean; message?: string };
|
||||
};
|
||||
|
||||
if (!projectPath || !branchName || !worktreePath) {
|
||||
@@ -35,10 +30,7 @@ export function createMergeHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the target branch (default to 'main')
|
||||
const mergeTo = targetBranch || 'main';
|
||||
|
||||
// Validate source branch exists
|
||||
// Validate branch exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
@@ -49,44 +41,12 @@ export function createMergeHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Merge the feature branch
|
||||
const mergeCmd = options?.squash
|
||||
? `git merge --squash ${branchName}`
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
|
||||
|
||||
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;
|
||||
}
|
||||
await execAsync(mergeCmd, { cwd: projectPath });
|
||||
|
||||
// If squash merge, need to commit
|
||||
if (options?.squash) {
|
||||
@@ -95,46 +55,17 @@ export function createMergeHandler() {
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally delete the worktree and branch after merging
|
||||
let worktreeDeleted = false;
|
||||
let branchDeleted = false;
|
||||
|
||||
if (options?.deleteWorktreeAndBranch) {
|
||||
// Remove the worktree
|
||||
try {
|
||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
// Try with prune if remove fails
|
||||
try {
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch (but not main/master)
|
||||
if (branchName !== 'main' && branchName !== 'master') {
|
||||
if (!isValidBranchName(branchName)) {
|
||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||
} else {
|
||||
try {
|
||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||
branchDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean up worktree and branch
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Cleanup errors are non-fatal
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
mergedBranch: branchName,
|
||||
targetBranch: mergeTo,
|
||||
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
||||
});
|
||||
res.json({ success: true, mergedBranch: branchName });
|
||||
} catch (error) {
|
||||
logError(error, 'Merge worktree failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -15,10 +15,9 @@ const execAsync = promisify(exec);
|
||||
export function createPushHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, force, remote } = req.body as {
|
||||
const { worktreePath, force } = req.body as {
|
||||
worktreePath: string;
|
||||
force?: boolean;
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -35,18 +34,15 @@ 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 ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
// Try setting upstream
|
||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
}
|
||||
@@ -56,7 +52,7 @@ export function createPushHandler() {
|
||||
result: {
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||
message: `Successfully pushed ${branchName} to origin`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,10 +28,7 @@ export function createStatusHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
// 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);
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
getProviderByModelId,
|
||||
getActiveClaudeApiProfile,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -275,29 +275,11 @@ 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}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -324,16 +306,10 @@ 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: modelForSdk,
|
||||
sessionModel: sessionModelForSdk,
|
||||
model: model,
|
||||
sessionModel: session.model,
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
@@ -409,8 +385,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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
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
@@ -468,41 +468,10 @@ 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
|
||||
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;
|
||||
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,7 +550,7 @@ export class ClaudeUsageService {
|
||||
sectionLabel: string,
|
||||
type: string
|
||||
): { percentage: number; resetTime: string; resetText: string } {
|
||||
let percentage: number | null = null;
|
||||
let percentage = 0;
|
||||
let resetTime = this.getDefaultResetTime(type);
|
||||
let resetText = '';
|
||||
|
||||
@@ -595,7 +564,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
if (sectionIndex === -1) {
|
||||
return { percentage: 0, resetTime, resetText };
|
||||
return { percentage, resetTime, resetText };
|
||||
}
|
||||
|
||||
// Look at the lines following the section header (within a window of 5 lines)
|
||||
@@ -603,8 +572,7 @@ export class ClaudeUsageService {
|
||||
|
||||
for (const line of searchWindow) {
|
||||
// Extract percentage - only take the first match (avoid picking up next section's data)
|
||||
// Use null to track "not found" since 0% is a valid percentage (100% left = 0% used)
|
||||
if (percentage === null) {
|
||||
if (percentage === 0) {
|
||||
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
|
||||
if (percentMatch) {
|
||||
const value = parseInt(percentMatch[1], 10);
|
||||
@@ -616,31 +584,18 @@ export class ClaudeUsageService {
|
||||
|
||||
// Extract reset time - only take the first match
|
||||
if (!resetText && line.toLowerCase().includes('reset')) {
|
||||
// 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];
|
||||
}
|
||||
resetText = line;
|
||||
}
|
||||
}
|
||||
|
||||
// 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: percentage ?? 0, resetTime, resetText };
|
||||
return { percentage, resetTime, resetText };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,7 +624,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;
|
||||
@@ -694,11 +649,8 @@ 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(
|
||||
/(?: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
|
||||
/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
|
||||
);
|
||||
if (dateMatch) {
|
||||
const monthName = dateMatch[1];
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -58,7 +57,6 @@ interface HookContext {
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
@@ -85,22 +83,19 @@ 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, event history service, and feature loader
|
||||
* Initialize the service with event emitter, settings service, and event history service
|
||||
*/
|
||||
initialize(
|
||||
emitter: EventEmitter,
|
||||
settingsService: SettingsService,
|
||||
eventHistoryService?: EventHistoryService,
|
||||
featureLoader?: FeatureLoader
|
||||
eventHistoryService?: EventHistoryService
|
||||
): void {
|
||||
this.emitter = emitter;
|
||||
this.settingsService = settingsService;
|
||||
this.eventHistoryService = eventHistoryService || null;
|
||||
this.featureLoader = featureLoader || null;
|
||||
|
||||
// Subscribe to events
|
||||
this.unsubscribe = emitter.subscribe((type, payload) => {
|
||||
@@ -125,7 +120,6 @@ export class EventHookService {
|
||||
this.emitter = null;
|
||||
this.settingsService = null;
|
||||
this.eventHistoryService = null;
|
||||
this.featureLoader = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,23 +149,9 @@ 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,
|
||||
@@ -333,7 +313,6 @@ export class EventHookService {
|
||||
eventType: context.eventType,
|
||||
timestamp: context.timestamp,
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
|
||||
@@ -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, getProviderByModelId } from '../lib/settings-helpers.js';
|
||||
import { getPromptCustomization, getActiveClaudeApiProfile } from '../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -208,27 +208,7 @@ export class IdeationService {
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
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;
|
||||
}
|
||||
}
|
||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
|
||||
// Create SDK options
|
||||
const sdkOptions = createChatOptions({
|
||||
@@ -243,6 +223,12 @@ export class IdeationService {
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: message,
|
||||
model: bareModel,
|
||||
@@ -252,7 +238,7 @@ 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
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
@@ -700,8 +686,11 @@ 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();
|
||||
// Get active Claude API profile for alternative endpoint configuration
|
||||
const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile(
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: prompt.prompt,
|
||||
@@ -713,6 +702,7 @@ export class IdeationService {
|
||||
// Disable all tools - we just want text generation, not codebase analysis
|
||||
allowedTools: [],
|
||||
abortController: new AbortController(),
|
||||
claudeApiProfile, // Pass active Claude API profile for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ import type {
|
||||
WorktreeInfo,
|
||||
PhaseModelConfig,
|
||||
PhaseModelEntry,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
ProviderModel,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
@@ -44,12 +41,7 @@ import {
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
migrateModelId,
|
||||
migrateCursorModelIds,
|
||||
migrateOpencodeModelIds,
|
||||
} from '@automaker/types';
|
||||
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
@@ -209,28 +201,6 @@ export class SettingsService {
|
||||
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;
|
||||
@@ -315,139 +285,6 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -570,29 +407,18 @@ 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 (
|
||||
nextVal &&
|
||||
typeof nextVal === 'object' &&
|
||||
!Array.isArray(nextVal) &&
|
||||
Object.keys(nextVal).length === 0 &&
|
||||
curVal &&
|
||||
typeof curVal === 'object' &&
|
||||
!Array.isArray(curVal) &&
|
||||
Object.keys(curVal).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates[key];
|
||||
}
|
||||
};
|
||||
|
||||
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
|
||||
ignoreEmptyObjectOverwrite('autoModeByWorktree');
|
||||
if (
|
||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
||||
current.lastSelectedSessionByProject &&
|
||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
||||
}
|
||||
|
||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||
if (attemptedProjectWipe) {
|
||||
@@ -806,27 +632,6 @@ 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}`);
|
||||
|
||||
@@ -912,7 +717,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) || DEFAULT_MAX_CONCURRENCY,
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
enableDependencyBlocking:
|
||||
|
||||
@@ -23,16 +23,6 @@ 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 {
|
||||
|
||||
@@ -41,14 +41,13 @@ describe('model-resolver.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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'];
|
||||
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'];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
// Should pass through unchanged (could be provider models)
|
||||
expect(result).toBe(model);
|
||||
// Should fall back to default since these aren't supported
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,12 +73,12 @@ describe('model-resolver.ts', () => {
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it('should pass through unknown model key unchanged (no warning)', () => {
|
||||
it('should return default for unknown model key', () => {
|
||||
const result = resolveModelString('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();
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown model key "unknown-model"')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
|
||||
@@ -1311,317 +1311,4 @@ 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,59 +124,6 @@ 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', () => {
|
||||
|
||||
@@ -63,10 +63,7 @@ describe('IdeationService', () => {
|
||||
} as unknown as EventEmitter;
|
||||
|
||||
// Create mock settings service
|
||||
mockSettingsService = {
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
mockSettingsService = {} as SettingsService;
|
||||
|
||||
// Create mock feature loader
|
||||
mockFeatureLoader = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.13.0",
|
||||
"version": "0.12.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -80,8 +80,7 @@
|
||||
"@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.17",
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-router": "1.141.6",
|
||||
"@uiw/react-codemirror": "4.25.4",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
@@ -147,7 +146,6 @@
|
||||
"productName": "Automaker",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"npmRebuild": false,
|
||||
"publish": null,
|
||||
"afterPack": "./scripts/rebuild-server-natives.cjs",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
|
||||
@@ -1,40 +1,115 @@
|
||||
/**
|
||||
* Claude Usage Popover
|
||||
*
|
||||
* Displays Claude API usage statistics using React Query for data fetching.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useClaudeUsage } from '@/hooks/queries';
|
||||
|
||||
// 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;
|
||||
|
||||
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';
|
||||
|
||||
// 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)
|
||||
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
|
||||
const isStale = useMemo(() => {
|
||||
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||
}, [dataUpdatedAt]);
|
||||
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]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
@@ -69,6 +144,7 @@ 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;
|
||||
@@ -169,10 +245,10 @@ export function ClaudeUsagePopover() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||
onClick={() => !isFetching && refetch()}
|
||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
||||
onClick={() => !loading && fetchUsage(false)}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,16 +259,26 @@ 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 instanceof Error ? error.message : 'Failed to fetch usage'}
|
||||
</p>
|
||||
<p className="text-sm font-medium">{error.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Make sure Claude CLI is installed and authenticated via{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
||||
{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>
|
||||
) : isLoading || !claudeUsage ? (
|
||||
) : !claudeUsage ? (
|
||||
// Loading state
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useCodexUsage } from '@/hooks/queries';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -22,6 +23,9 @@ 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);
|
||||
@@ -59,39 +63,95 @@ 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 !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||
}, [dataUpdatedAt]);
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
// 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 fetchUsage = useCallback(
|
||||
async (isAutoRefresh = false) => {
|
||||
if (!isAutoRefresh) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
setError({
|
||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
||||
message: 'Codex API bridge not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await api.codex.getUsage();
|
||||
if ('error' in data) {
|
||||
// Check if it's the "not available" error
|
||||
if (
|
||||
data.message?.includes('not available') ||
|
||||
data.message?.includes('does not provide')
|
||||
) {
|
||||
setError({
|
||||
code: ERROR_CODES.NOT_AVAILABLE,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
} else {
|
||||
setError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCodexUsage(data);
|
||||
} catch (err) {
|
||||
setError({
|
||||
code: ERROR_CODES.UNKNOWN,
|
||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
||||
});
|
||||
} finally {
|
||||
if (!isAutoRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[setCodexUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale (only if authenticated)
|
||||
useEffect(() => {
|
||||
if (isStale && isCodexAuthenticated) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
if (message.includes('bridge') || message.includes('API')) {
|
||||
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||
}, [isStale, isCodexAuthenticated, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not authenticated
|
||||
if (!isCodexAuthenticated) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!codexUsage || isStale) {
|
||||
fetchUsage();
|
||||
}
|
||||
}
|
||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||
}, [queryError]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
@@ -229,10 +289,10 @@ export function CodexUsagePopover() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||
onClick={() => !isFetching && refetch()}
|
||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
||||
onClick={() => !loading && fetchUsage(false)}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
persistSettings,
|
||||
getCurrentSettings,
|
||||
} = useBoardBackgroundSettings();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -57,31 +55,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
const backgroundSettings =
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||
|
||||
// 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 cardOpacity = backgroundSettings.cardOpacity;
|
||||
const columnOpacity = backgroundSettings.columnOpacity;
|
||||
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||
const imageVersion = backgroundSettings.imageVersion;
|
||||
|
||||
@@ -219,40 +198,21 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// 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[]) => {
|
||||
// Live update opacity when sliders change (with persistence)
|
||||
const handleCardOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
||||
await setCardOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardOpacity]
|
||||
);
|
||||
|
||||
// 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[]) => {
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
||||
await setColumnOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setColumnOpacity]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
@@ -279,22 +239,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
// 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[]) => {
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
||||
await setCardBorderOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardBorderOpacity]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
@@ -428,12 +378,11 @@ 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">{localCardOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardOpacity]}
|
||||
value={[cardOpacity]}
|
||||
onValueChange={handleCardOpacityChange}
|
||||
onValueCommit={handleCardOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -444,12 +393,11 @@ 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">{localColumnOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localColumnOpacity]}
|
||||
value={[columnOpacity]}
|
||||
onValueChange={handleColumnOpacityChange}
|
||||
onValueCommit={handleColumnOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -498,12 +446,11 @@ 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">{localCardBorderOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardBorderOpacity]}
|
||||
value={[cardBorderOpacity]}
|
||||
onValueChange={handleCardBorderOpacityChange}
|
||||
onValueCommit={handleCardBorderOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useWorkspaceDirectories } from '@/hooks/queries';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
|
||||
interface WorkspaceDirectory {
|
||||
name: string;
|
||||
@@ -23,15 +24,41 @@ interface WorkspacePickerModalProps {
|
||||
}
|
||||
|
||||
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
||||
// React Query hook - only fetch when modal is open
|
||||
const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
|
||||
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]);
|
||||
|
||||
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">
|
||||
@@ -53,19 +80,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && !isLoading && (
|
||||
{error && !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">{errorMessage}</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && directories.length === 0 && (
|
||||
{!isLoading && !error && 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" />
|
||||
@@ -76,7 +103,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !errorMessage && directories.length > 0 && (
|
||||
{!isLoading && !error && directories.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{directories.map((dir) => (
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState, memo, useCallback } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -6,67 +6,35 @@ import { cn } from '@/lib/utils';
|
||||
import { type ThemeMode, useAppStore } from '@/store/app-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import {
|
||||
PROJECT_DARK_THEMES,
|
||||
PROJECT_LIGHT_THEMES,
|
||||
THEME_SUBMENU_CONSTANTS,
|
||||
} from '@/components/layout/sidebar/constants';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
|
||||
import { useThemePreview } from '@/components/layout/sidebar/hooks';
|
||||
|
||||
/**
|
||||
* Constant representing the "use global theme" option.
|
||||
* An empty string is used to indicate that no project-specific theme is set.
|
||||
*/
|
||||
// Constant for "use global theme" option
|
||||
const USE_GLOBAL_THEME = '' as const;
|
||||
|
||||
/**
|
||||
* Z-index values for context menu layering.
|
||||
* Ensures proper stacking order when menus overlap.
|
||||
*/
|
||||
// Constants for z-index values
|
||||
const Z_INDEX = {
|
||||
/** Base z-index for the main context menu */
|
||||
CONTEXT_MENU: 100,
|
||||
/** Higher z-index for theme submenu to appear above parent menu */
|
||||
THEME_SUBMENU: 101,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Represents a selectable theme option in the theme submenu.
|
||||
* Uses ThemeMode from app-store for type safety.
|
||||
*/
|
||||
// Theme option type - using ThemeMode for type safety
|
||||
interface ThemeOption {
|
||||
/** The theme mode value (e.g., 'dark', 'light', 'dracula') */
|
||||
value: ThemeMode;
|
||||
/** Display label for the theme option */
|
||||
label: string;
|
||||
/** Lucide icon component to display alongside the label */
|
||||
icon: LucideIcon;
|
||||
/** CSS color value for the icon */
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ThemeButton component.
|
||||
* Defines the interface for rendering individual theme selection buttons.
|
||||
*/
|
||||
// Reusable theme button component to avoid duplication (DRY principle)
|
||||
interface ThemeButtonProps {
|
||||
/** The theme option data to display */
|
||||
option: ThemeOption;
|
||||
/** Whether this theme is currently selected */
|
||||
isSelected: boolean;
|
||||
/** Handler for pointer enter events (used for preview) */
|
||||
onPointerEnter: () => void;
|
||||
/** Handler for pointer leave events (used to clear preview) */
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler for click events (used to select theme) */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable button component for individual theme options.
|
||||
* Implements hover preview and selection functionality.
|
||||
* Memoized to prevent unnecessary re-renders when parent state changes.
|
||||
*/
|
||||
const ThemeButton = memo(function ThemeButton({
|
||||
option,
|
||||
isSelected,
|
||||
@@ -95,33 +63,17 @@ const ThemeButton = memo(function ThemeButton({
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the ThemeColumn component.
|
||||
* Defines the interface for rendering a column of related theme options (e.g., dark or light themes).
|
||||
*/
|
||||
// Reusable theme column component
|
||||
interface ThemeColumnProps {
|
||||
/** Column header title (e.g., "Dark", "Light") */
|
||||
title: string;
|
||||
/** Icon to display in the column header */
|
||||
icon: LucideIcon;
|
||||
/** Array of theme options to display in this column */
|
||||
themes: ThemeOption[];
|
||||
/** Currently selected theme value, or null if using global theme */
|
||||
selectedTheme: ThemeMode | null;
|
||||
/** Handler called when user hovers over a theme option for preview */
|
||||
onPreviewEnter: (value: ThemeMode) => void;
|
||||
/** Handler called when user stops hovering over a theme option */
|
||||
onPreviewLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler called when user clicks to select a theme */
|
||||
onSelect: (value: ThemeMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable column component for displaying themed options.
|
||||
* Renders a group of related themes (e.g., all dark themes or all light themes)
|
||||
* with a header and scrollable list of ThemeButton components.
|
||||
* Memoized to prevent unnecessary re-renders.
|
||||
*/
|
||||
const ThemeColumn = memo(function ThemeColumn({
|
||||
title,
|
||||
icon: Icon,
|
||||
@@ -153,36 +105,13 @@ const ThemeColumn = memo(function ThemeColumn({
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the ProjectContextMenu component.
|
||||
* Defines the interface for the project right-click context menu.
|
||||
*/
|
||||
interface ProjectContextMenuProps {
|
||||
/** The project this context menu is for */
|
||||
project: Project;
|
||||
/** Screen coordinates where the context menu should appear */
|
||||
position: { x: number; y: number };
|
||||
/** Callback to close the context menu */
|
||||
onClose: () => void;
|
||||
/** Callback when user selects "Edit Name & Icon" option */
|
||||
onEdit: (project: Project) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A context menu component for project-specific actions.
|
||||
*
|
||||
* Provides options for:
|
||||
* - Editing project name and icon
|
||||
* - Setting project-specific theme (with live preview on hover)
|
||||
* - Removing project from the workspace
|
||||
*
|
||||
* Features viewport-aware positioning for the theme submenu to prevent
|
||||
* overflow, and implements delayed hover handling to improve UX when
|
||||
* navigating between the trigger button and submenu.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The rendered context menu or null if not visible
|
||||
*/
|
||||
export function ProjectContextMenu({
|
||||
project,
|
||||
position,
|
||||
@@ -201,82 +130,9 @@ export function ProjectContextMenu({
|
||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
|
||||
|
||||
// Handler to open theme submenu and cancel any pending close
|
||||
const handleThemeMenuEnter = useCallback(() => {
|
||||
// Cancel any pending close timeout
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
setShowThemeSubmenu(true);
|
||||
}, []);
|
||||
|
||||
// Handler to close theme submenu with a small delay
|
||||
// This prevents the submenu from closing when mouse crosses the gap between trigger and submenu
|
||||
const handleThemeMenuLeave = useCallback(() => {
|
||||
// Add a small delay before closing to allow mouse to reach submenu
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}, 100); // 100ms delay is enough to cross the gap
|
||||
}, [setPreviewTheme]);
|
||||
|
||||
/**
|
||||
* Calculates theme submenu position to prevent viewport overflow.
|
||||
*
|
||||
* This memoized calculation determines the optimal vertical position and maximum
|
||||
* height for the theme submenu based on the current viewport dimensions and
|
||||
* the trigger button's position.
|
||||
*
|
||||
* @returns Object containing:
|
||||
* - top: Vertical offset from default position (negative values shift submenu up)
|
||||
* - maxHeight: Maximum height constraint to prevent overflow with scrolling
|
||||
*/
|
||||
const submenuPosition = useMemo(() => {
|
||||
const { ESTIMATED_SUBMENU_HEIGHT, COLLISION_PADDING, THEME_BUTTON_OFFSET } =
|
||||
THEME_SUBMENU_CONSTANTS;
|
||||
|
||||
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
|
||||
// Calculate where the submenu's bottom edge would be if positioned normally
|
||||
const submenuBottomY = position.y + THEME_BUTTON_OFFSET + ESTIMATED_SUBMENU_HEIGHT;
|
||||
|
||||
// Check if submenu would overflow bottom of viewport
|
||||
const wouldOverflowBottom = submenuBottomY > viewportHeight - COLLISION_PADDING;
|
||||
|
||||
// If it would overflow, calculate how much to shift it up
|
||||
if (wouldOverflowBottom) {
|
||||
// Calculate the offset needed to align submenu bottom with viewport bottom minus padding
|
||||
const overflowAmount = submenuBottomY - (viewportHeight - COLLISION_PADDING);
|
||||
return {
|
||||
top: -overflowAmount,
|
||||
maxHeight: Math.min(ESTIMATED_SUBMENU_HEIGHT, viewportHeight - COLLISION_PADDING * 2),
|
||||
};
|
||||
}
|
||||
|
||||
// Default: submenu opens at top of parent (aligned with the theme button)
|
||||
return {
|
||||
top: 0,
|
||||
maxHeight: Math.min(
|
||||
ESTIMATED_SUBMENU_HEIGHT,
|
||||
viewportHeight - position.y - THEME_BUTTON_OFFSET - COLLISION_PADDING
|
||||
),
|
||||
};
|
||||
}, [position.y]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: globalThis.MouseEvent) => {
|
||||
// Don't close if a confirmation dialog is open (dialog is in a portal)
|
||||
@@ -386,8 +242,11 @@ export function ProjectContextMenu({
|
||||
{/* Theme Submenu Trigger */}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
onMouseEnter={() => setShowThemeSubmenu(true)}
|
||||
onMouseLeave={() => {
|
||||
setShowThemeSubmenu(false);
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
|
||||
@@ -414,18 +273,13 @@ export function ProjectContextMenu({
|
||||
<div
|
||||
ref={themeSubmenuRef}
|
||||
className={cn(
|
||||
'absolute left-full ml-1 min-w-[420px] rounded-lg',
|
||||
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
|
||||
'bg-popover text-popover-foreground',
|
||||
'border border-border shadow-lg',
|
||||
'animate-in fade-in zoom-in-95 duration-100'
|
||||
)}
|
||||
style={{
|
||||
zIndex: Z_INDEX.THEME_SUBMENU,
|
||||
top: `${submenuPosition.top}px`,
|
||||
}}
|
||||
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
|
||||
data-testid="project-theme-submenu"
|
||||
onMouseEnter={handleThemeMenuEnter}
|
||||
onMouseLeave={handleThemeMenuLeave}
|
||||
>
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
@@ -452,13 +306,7 @@ export function ProjectContextMenu({
|
||||
<div className="h-px bg-border my-2" />
|
||||
|
||||
{/* Two Column Layout - Using reusable ThemeColumn component */}
|
||||
{/* Dynamic max height with scroll for viewport overflow handling */}
|
||||
<div
|
||||
className="flex gap-2 overflow-y-auto scrollbar-styled"
|
||||
style={{
|
||||
maxHeight: `${Math.max(0, submenuPosition.maxHeight - THEME_SUBMENU_CONSTANTS.SUBMENU_HEADER_HEIGHT)}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<ThemeColumn
|
||||
title="Dark"
|
||||
icon={Moon}
|
||||
|
||||
@@ -30,41 +30,17 @@ import {
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { SortableProjectItem, ThemeMenuItem } from './';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES, THEME_SUBMENU_CONSTANTS } from '../constants';
|
||||
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
|
||||
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
|
||||
/**
|
||||
* Props for the ProjectSelectorWithOptions component.
|
||||
* Defines the interface for the project selector dropdown with additional options menu.
|
||||
*/
|
||||
interface ProjectSelectorWithOptionsProps {
|
||||
/** Whether the sidebar is currently expanded */
|
||||
sidebarOpen: boolean;
|
||||
/** Whether the project picker dropdown is currently open */
|
||||
isProjectPickerOpen: boolean;
|
||||
/** Callback to control the project picker dropdown open state */
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
/** Callback to show the delete project confirmation dialog */
|
||||
setShowDeleteProjectDialog: (show: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project selector component with search, drag-and-drop reordering, and options menu.
|
||||
*
|
||||
* Features:
|
||||
* - Searchable dropdown for quick project switching
|
||||
* - Drag-and-drop reordering of projects
|
||||
* - Project-specific theme selection with live preview
|
||||
* - Project history navigation (previous/next)
|
||||
* - Option to move project to trash
|
||||
*
|
||||
* The component uses viewport-aware positioning via THEME_SUBMENU_CONSTANTS
|
||||
* for consistent submenu behavior across the application.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The rendered project selector or null if sidebar is closed or no projects exist
|
||||
*/
|
||||
export function ProjectSelectorWithOptions({
|
||||
sidebarOpen,
|
||||
isProjectPickerOpen,
|
||||
@@ -270,7 +246,6 @@ export function ProjectSelectorWithOptions({
|
||||
<DropdownMenuSubContent
|
||||
className="w-[420px] bg-popover/95 backdrop-blur-xl"
|
||||
data-testid="project-theme-menu"
|
||||
collisionPadding={THEME_SUBMENU_CONSTANTS.COLLISION_PADDING}
|
||||
onPointerLeave={() => {
|
||||
// Clear preview theme when leaving the dropdown
|
||||
setPreviewTheme(null);
|
||||
@@ -311,8 +286,7 @@ export function ProjectSelectorWithOptions({
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Two Column Layout */}
|
||||
{/* Max height with scroll to ensure all themes are visible when menu is near screen edge */}
|
||||
<div className="flex gap-2 p-2 max-h-[60vh] overflow-y-auto scrollbar-styled">
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* Dark Themes Column */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||
|
||||
/**
|
||||
* Shared constants for theme submenu positioning and layout.
|
||||
* Used across project-context-menu and project-selector-with-options components
|
||||
* to ensure consistent viewport-aware positioning and styling.
|
||||
*/
|
||||
export const THEME_SUBMENU_CONSTANTS = {
|
||||
/**
|
||||
* Estimated total height of the theme submenu content in pixels.
|
||||
* Includes all theme options, headers, padding, and "Use Global" button.
|
||||
*/
|
||||
ESTIMATED_SUBMENU_HEIGHT: 620,
|
||||
|
||||
/**
|
||||
* Padding from viewport edges to prevent submenu overflow.
|
||||
* Applied to both top and bottom edges when calculating available space.
|
||||
*/
|
||||
COLLISION_PADDING: 32,
|
||||
|
||||
/**
|
||||
* Vertical offset from context menu top to the "Project Theme" button.
|
||||
* Used for calculating submenu position relative to trigger button.
|
||||
*/
|
||||
THEME_BUTTON_OFFSET: 50,
|
||||
|
||||
/**
|
||||
* Height reserved for submenu header area (includes "Use Global" button and separator).
|
||||
* Subtracted from maxHeight to get scrollable content area height.
|
||||
*/
|
||||
SUBMENU_HEADER_HEIGHT: 80,
|
||||
} as const;
|
||||
|
||||
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
|
||||
@@ -12,7 +12,9 @@ interface UseProjectCreationProps {
|
||||
upsertAndSetCurrentProject: (path: string, name: string) => Project;
|
||||
}
|
||||
|
||||
export function useProjectCreation({ upsertAndSetCurrentProject }: UseProjectCreationProps) {
|
||||
export function useProjectCreation({
|
||||
upsertAndSetCurrentProject,
|
||||
}: UseProjectCreationProps) {
|
||||
// Modal state
|
||||
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect } 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');
|
||||
@@ -23,8 +22,6 @@ 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';
|
||||
|
||||
@@ -105,7 +102,7 @@ export function SessionManager({
|
||||
onQuickCreateRef,
|
||||
}: SessionManagerProps) {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
@@ -116,14 +113,8 @@ 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 = useCallback(async (sessionList: SessionListItem[]) => {
|
||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
|
||||
@@ -143,25 +134,25 @@ export function SessionManager({
|
||||
}
|
||||
|
||||
setRunningSessions(runningIds);
|
||||
}, []);
|
||||
};
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}, [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]);
|
||||
loadSessions();
|
||||
}, []);
|
||||
|
||||
// Periodically check running state for sessions (useful for detecting when agents finish)
|
||||
useEffect(() => {
|
||||
@@ -175,7 +166,7 @@ export function SessionManager({
|
||||
}, 3000); // Check every 3 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
|
||||
|
||||
// Create new session with random name
|
||||
const handleCreateSession = async () => {
|
||||
@@ -189,7 +180,7 @@ export function SessionManager({
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName('');
|
||||
setIsCreating(false);
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
@@ -204,7 +195,7 @@ export function SessionManager({
|
||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
@@ -231,7 +222,7 @@ export function SessionManager({
|
||||
if (result.success) {
|
||||
setEditingSessionId(null);
|
||||
setEditingName('');
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,7 +241,7 @@ export function SessionManager({
|
||||
if (currentSessionId === sessionId) {
|
||||
onSelectSession(null);
|
||||
}
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
} else {
|
||||
logger.error('[SessionManager] Archive failed:', result.error);
|
||||
}
|
||||
@@ -270,7 +261,7 @@ export function SessionManager({
|
||||
try {
|
||||
const result = await api.sessions.unarchive(sessionId);
|
||||
if (result.success) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
} else {
|
||||
logger.error('[SessionManager] Unarchive failed:', result.error);
|
||||
}
|
||||
@@ -292,7 +283,7 @@ export function SessionManager({
|
||||
|
||||
const result = await api.sessions.delete(sessionId);
|
||||
if (result.success) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
if (currentSessionId === sessionId) {
|
||||
// Switch to another session or create a new one
|
||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
||||
@@ -314,7 +305,7 @@ export function SessionManager({
|
||||
await api.sessions.delete(session.id);
|
||||
}
|
||||
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
setIsDeleteAllArchivedDialogOpen(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
File,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} 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,44 +350,56 @@ 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());
|
||||
|
||||
// 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
|
||||
);
|
||||
const loadDiffs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// 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);
|
||||
// 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]);
|
||||
|
||||
// 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;
|
||||
// Load diffs when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
loadDiffs();
|
||||
}
|
||||
}, [isExpanded, loadDiffs]);
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
|
||||
@@ -523,15 +523,6 @@ 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';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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)} />;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } 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,63 +61,22 @@ 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) {
|
||||
@@ -135,9 +95,137 @@ export function UsagePopover() {
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
// Refetch functions for manual refresh
|
||||
const fetchClaudeUsage = () => refetchClaude();
|
||||
const fetchCodexUsage = () => refetchCodex();
|
||||
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,
|
||||
]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
@@ -329,7 +417,7 @@ export function UsagePopover() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
||||
onClick={() => !claudeLoading && fetchClaudeUsage()}
|
||||
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -436,7 +524,7 @@ export function UsagePopover() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
||||
onClick={() => !codexLoading && fetchCodexUsage()}
|
||||
onClick={() => !codexLoading && fetchCodexUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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 {
|
||||
@@ -74,7 +72,6 @@ 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(
|
||||
@@ -650,11 +647,6 @@ ${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);
|
||||
@@ -664,7 +656,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
} finally {
|
||||
setIsGeneratingFeatureList(false);
|
||||
}
|
||||
}, [currentProject, projectAnalysis, queryClient]);
|
||||
}, [currentProject, projectAnalysis]);
|
||||
|
||||
// Toggle folder expansion
|
||||
const toggleFolder = (path: string) => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -36,7 +35,6 @@ import { toast } from 'sonner';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
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';
|
||||
@@ -50,21 +48,19 @@ 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, MergeConflictInfo } from './board-view/worktree-panel/types';
|
||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -83,10 +79,6 @@ 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']> = [];
|
||||
@@ -96,8 +88,8 @@ const logger = createLogger('Board');
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
maxConcurrency: legacyMaxConcurrency,
|
||||
setMaxConcurrency: legacySetMaxConcurrency,
|
||||
maxConcurrency,
|
||||
setMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
@@ -116,37 +108,9 @@ export function BoardView() {
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
setPipelineConfig,
|
||||
} = 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);
|
||||
} = useAppStore();
|
||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
||||
// 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
|
||||
@@ -185,7 +149,7 @@ export function BoardView() {
|
||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
||||
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -297,6 +261,11 @@ 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();
|
||||
|
||||
@@ -362,22 +331,10 @@ export function BoardView() {
|
||||
fetchBranches();
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
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)
|
||||
);
|
||||
@@ -387,7 +344,7 @@ export function BoardView() {
|
||||
return columnCollisions;
|
||||
}
|
||||
|
||||
// Priority 3: Fallback to rectangle intersection
|
||||
// Otherwise, use rectangle intersection for cards
|
||||
return rectIntersection(args);
|
||||
}, []);
|
||||
|
||||
@@ -417,6 +374,14 @@ 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;
|
||||
@@ -442,16 +407,6 @@ 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;
|
||||
|
||||
@@ -460,15 +415,6 @@ 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
|
||||
@@ -566,14 +512,14 @@ export function BoardView() {
|
||||
|
||||
try {
|
||||
// Determine final branch name based on work mode:
|
||||
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
|
||||
// - 'current': Empty string to clear branch assignment (work on main/current branch)
|
||||
// - 'auto': Auto-generate branch name based on current branch
|
||||
// - 'custom': Use the provided branch name
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
// Empty string clears the branch assignment, moving features to main/current branch
|
||||
finalBranchName = '';
|
||||
} 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
|
||||
@@ -636,8 +582,10 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
|
||||
|
||||
if (result.success) {
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
// Update local state
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, finalUpdates);
|
||||
});
|
||||
toast.success(`Updated ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
@@ -653,11 +601,10 @@ export function BoardView() {
|
||||
[
|
||||
currentProject,
|
||||
selectedFeatureIds,
|
||||
loadFeatures,
|
||||
updateFeature,
|
||||
exitSelectionMode,
|
||||
getPrimaryWorktreeBranch,
|
||||
addAndSelectWorktree,
|
||||
currentWorktreeBranch,
|
||||
setWorktreeRefreshKey,
|
||||
]
|
||||
);
|
||||
@@ -781,8 +728,10 @@ export function BoardView() {
|
||||
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
|
||||
|
||||
if (result.success) {
|
||||
// Invalidate React Query cache to refetch features with server-updated values
|
||||
loadFeatures();
|
||||
// Update local state for all features
|
||||
featureIds.forEach((featureId) => {
|
||||
updateFeature(featureId, updates);
|
||||
});
|
||||
toast.success(`Verified ${result.updatedCount} features`);
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
@@ -794,7 +743,7 @@ export function BoardView() {
|
||||
logger.error('Bulk verify failed:', error);
|
||||
toast.error('Failed to verify features');
|
||||
}
|
||||
}, [currentProject, selectedFeatureIds, loadFeatures, exitSelectionMode]);
|
||||
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
@@ -841,15 +790,10 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
// 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}`;
|
||||
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
|
||||
@@ -889,48 +833,6 @@ 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]) => {
|
||||
@@ -1025,13 +927,7 @@ export function BoardView() {
|
||||
});
|
||||
|
||||
// Use drag and drop hook
|
||||
const {
|
||||
activeFeature,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
pendingDependencyLink,
|
||||
clearPendingDependencyLink,
|
||||
} = useBoardDragDrop({
|
||||
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
|
||||
features: hookFeatures,
|
||||
currentProject,
|
||||
runningAutoTasks,
|
||||
@@ -1039,50 +935,6 @@ 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,
|
||||
@@ -1094,7 +946,9 @@ export function BoardView() {
|
||||
});
|
||||
|
||||
// Build columnFeaturesMap for ListView
|
||||
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
||||
const pipelineConfig = currentProject?.path
|
||||
? pipelineConfigByProject[currentProject.path] || null
|
||||
: null;
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||
const map: Record<string, typeof hookFeatures> = {};
|
||||
@@ -1273,21 +1127,7 @@ export function BoardView() {
|
||||
projectPath={currentProject.path}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
if (enabled) {
|
||||
@@ -1313,148 +1153,133 @@ 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
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
onBranchDeletedDuringMerge={(branchName) => {
|
||||
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
||||
hookFeatures.forEach((feature) => {
|
||||
if (feature.branchName === branchName) {
|
||||
// Reset the feature's branch assignment - update both local state and persist
|
||||
const updates = {
|
||||
branchName: null as unknown as string | undefined,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
}
|
||||
});
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
|
||||
<WorktreePanel
|
||||
refreshTrigger={worktreeRefreshKey}
|
||||
projectPath={currentProject.path}
|
||||
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
|
||||
onDeleteWorktree={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowDeleteWorktreeDialog(true);
|
||||
}}
|
||||
onCommit={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCommitWorktreeDialog(true);
|
||||
}}
|
||||
onCreatePR={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreatePRDialog(true);
|
||||
}}
|
||||
onCreateBranch={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onMerge={(worktree) => {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowMergeWorktreeDialog(true);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
branchName: f.branchName,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* View Content - Kanban Board or List View */}
|
||||
{isListView ? (
|
||||
<ListView
|
||||
columnFeaturesMap={columnFeaturesMap}
|
||||
allFeatures={hookFeatures}
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={setSortColumn}
|
||||
actionHandlers={{
|
||||
onEdit: (feature) => setEditingFeature(feature),
|
||||
onDelete: (featureId) => handleDeleteFeature(featureId),
|
||||
onViewOutput: handleViewOutput,
|
||||
onVerify: handleVerifyFeature,
|
||||
onResume: handleResumeFeature,
|
||||
onForceStop: handleForceStopFeature,
|
||||
onManualVerify: handleManualVerify,
|
||||
onFollowUp: handleOpenFollowUp,
|
||||
onImplement: handleStartImplementation,
|
||||
onComplete: handleCompleteFeature,
|
||||
onViewPlan: (feature) => setViewPlanFeature(feature),
|
||||
onApprovePlan: handleOpenApprovalDialog,
|
||||
onSpawnTask: (feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onRowClick={(feature) => {
|
||||
if (feature.status === 'backlog') {
|
||||
setEditingFeature(feature);
|
||||
} else {
|
||||
handleViewOutput(feature);
|
||||
}
|
||||
}}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* View Content - Kanban Board or List View */}
|
||||
{isListView ? (
|
||||
<ListView
|
||||
columnFeaturesMap={columnFeaturesMap}
|
||||
allFeatures={hookFeatures}
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={setSortColumn}
|
||||
actionHandlers={{
|
||||
onEdit: (feature) => setEditingFeature(feature),
|
||||
onDelete: (featureId) => handleDeleteFeature(featureId),
|
||||
onViewOutput: handleViewOutput,
|
||||
onVerify: handleVerifyFeature,
|
||||
onResume: handleResumeFeature,
|
||||
onForceStop: handleForceStopFeature,
|
||||
onManualVerify: handleManualVerify,
|
||||
onFollowUp: handleOpenFollowUp,
|
||||
onImplement: handleStartImplementation,
|
||||
onComplete: handleCompleteFeature,
|
||||
onViewPlan: (feature) => setViewPlanFeature(feature),
|
||||
onApprovePlan: handleOpenApprovalDialog,
|
||||
onSpawnTask: (feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
viewMode={viewMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
},
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onRowClick={(feature) => {
|
||||
if (feature.status === 'backlog') {
|
||||
setEditingFeature(feature);
|
||||
} else {
|
||||
handleViewOutput(feature);
|
||||
}
|
||||
}}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
sensors={sensors}
|
||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
activeFeature={activeFeature}
|
||||
getColumnFeatures={getColumnFeatures}
|
||||
backgroundImageStyle={backgroundImageStyle}
|
||||
backgroundSettings={backgroundSettings}
|
||||
onEdit={(feature) => setEditingFeature(feature)}
|
||||
onDelete={(featureId) => handleDeleteFeature(featureId)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onVerify={handleVerifyFeature}
|
||||
onResume={handleResumeFeature}
|
||||
onForceStop={handleForceStopFeature}
|
||||
onManualVerify={handleManualVerify}
|
||||
onMoveBackToInProgress={handleMoveBackToInProgress}
|
||||
onFollowUp={handleOpenFollowUp}
|
||||
onComplete={handleCompleteFeature}
|
||||
onImplement={handleStartImplementation}
|
||||
onViewPlan={(feature) => setViewPlanFeature(feature)}
|
||||
onApprovePlan={handleOpenApprovalDialog}
|
||||
onSpawnTask={(feature) => {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
pipelineConfig={pipelineConfig}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
viewMode={viewMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Action Bar */}
|
||||
{isSelectionMode && (
|
||||
@@ -1548,15 +1373,6 @@ 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}
|
||||
@@ -1578,7 +1394,6 @@ export function BoardView() {
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
onNumberKeyPress={handleOutputModalNumberKeyPress}
|
||||
branchName={outputFeature?.branchName}
|
||||
/>
|
||||
|
||||
{/* Archive All Verified Dialog */}
|
||||
@@ -1604,11 +1419,6 @@ 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);
|
||||
}}
|
||||
/>
|
||||
@@ -1728,12 +1538,33 @@ export function BoardView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pull & Resolve Conflicts Dialog */}
|
||||
<PullResolveConflictsDialog
|
||||
open={showPullResolveConflictsDialog}
|
||||
onOpenChange={setShowPullResolveConflictsDialog}
|
||||
{/* Merge Worktree Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={showMergeWorktreeDialog}
|
||||
onOpenChange={setShowMergeWorktreeDialog}
|
||||
projectPath={currentProject.path}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onConfirm={handleConfirmResolveConflicts}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
|
||||
@@ -142,8 +142,7 @@ export function BoardHeader({
|
||||
onConcurrencyChange={onConcurrencyChange}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onAutoModeToggle={onAutoModeToggle}
|
||||
skipVerificationInAutoMode={skipVerificationInAutoMode}
|
||||
onSkipVerificationChange={setSkipVerificationInAutoMode}
|
||||
onOpenAutoModeSettings={() => {}}
|
||||
onOpenPlanDialog={onOpenPlanDialog}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
@@ -183,13 +182,6 @@ 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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useState, useMemo } from 'react';
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
@@ -15,7 +16,6 @@ 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
|
||||
@@ -50,62 +50,30 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
|
||||
|
||||
interface AgentInfoPanelProps {
|
||||
feature: Feature;
|
||||
projectPath: string;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
isCurrentAutoTask?: boolean;
|
||||
}
|
||||
|
||||
export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
export 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());
|
||||
|
||||
// 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]);
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -157,6 +125,73 @@ export const AgentInfoPanel = memo(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
|
||||
@@ -405,4 +440,4 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { memo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -33,7 +32,7 @@ interface CardActionsProps {
|
||||
onApprovePlan?: () => void;
|
||||
}
|
||||
|
||||
export const CardActions = memo(function CardActions({
|
||||
export function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
@@ -345,4 +344,4 @@ export const CardActions = memo(function CardActions({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// @ts-nocheck
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { 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 =
|
||||
@@ -19,7 +18,7 @@ interface CardBadgesProps {
|
||||
* CardBadges - Shows error badges below the card header
|
||||
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
||||
*/
|
||||
export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) {
|
||||
export function CardBadges({ feature }: CardBadgesProps) {
|
||||
if (!feature.error) {
|
||||
return null;
|
||||
}
|
||||
@@ -47,19 +46,14 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface PriorityBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
features: state.features,
|
||||
}))
|
||||
);
|
||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore();
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
@@ -229,4 +223,4 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { memo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
|
||||
@@ -8,10 +7,7 @@ interface CardContentSectionsProps {
|
||||
useWorktrees: boolean;
|
||||
}
|
||||
|
||||
export const CardContentSections = memo(function CardContentSections({
|
||||
feature,
|
||||
useWorktrees,
|
||||
}: CardContentSectionsProps) {
|
||||
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Target Branch Display */}
|
||||
@@ -52,4 +48,4 @@ export const CardContentSections = memo(function CardContentSections({
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { memo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -37,7 +37,7 @@ interface CardHeaderProps {
|
||||
onSpawnTask?: () => void;
|
||||
}
|
||||
|
||||
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
export function CardHeaderSection({
|
||||
feature,
|
||||
isDraggable,
|
||||
isCurrentAutoTask,
|
||||
@@ -378,4 +378,4 @@ export const CardHeaderSection = memo(function CardHeaderSection({
|
||||
/>
|
||||
</CardHeader>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// @ts-nocheck
|
||||
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
import { CardContentSections } from './card-content-sections';
|
||||
@@ -62,7 +61,6 @@ interface KanbanCardProps {
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
isOverlay?: boolean;
|
||||
reduceEffects?: boolean;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -96,18 +94,12 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
isOverlay,
|
||||
reduceEffects = false,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
selectionTarget = null,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees, currentProject } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
useWorktrees: state.useWorktrees,
|
||||
currentProject: state.currentProject,
|
||||
}))
|
||||
);
|
||||
const { useWorktrees } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -123,39 +115,12 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'waiting_approval' ||
|
||||
feature.status === 'verified' ||
|
||||
feature.status.startsWith('pipeline_') ||
|
||||
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable || isOverlay || isSelectionMode,
|
||||
});
|
||||
|
||||
// Make the card a drop target for creating dependency links
|
||||
// Only backlog cards can be link targets (to avoid complexity with running features)
|
||||
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: `card-drop-${feature.id}`,
|
||||
disabled: !isDroppable,
|
||||
data: {
|
||||
type: 'card',
|
||||
featureId: feature.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Combine refs for both draggable and droppable
|
||||
const setNodeRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef]
|
||||
);
|
||||
|
||||
const dndStyle = {
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
@@ -168,23 +133,16 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||
// Visual feedback when another card is being dragged over this one
|
||||
isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
|
||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
||||
);
|
||||
|
||||
const isInteractive = !isDragging && !isOverlay;
|
||||
const hasError = feature.error && !isCurrentAutoTask;
|
||||
|
||||
const innerCardClasses = cn(
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'kanban-card-content h-full relative shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isCurrentAutoTask &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
@@ -257,7 +215,6 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
feature={feature}
|
||||
projectPath={currentProject?.path ?? ''}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
@@ -17,11 +17,6 @@ interface KanbanColumnProps {
|
||||
hideScrollbar?: boolean;
|
||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||
width?: number;
|
||||
contentRef?: Ref<HTMLDivElement>;
|
||||
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
||||
contentClassName?: string;
|
||||
contentStyle?: CSSProperties;
|
||||
disableItemSpacing?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(function KanbanColumn({
|
||||
@@ -36,11 +31,6 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
width,
|
||||
contentRef,
|
||||
onScroll,
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
disableItemSpacing = false,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
@@ -88,19 +78,14 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
{/* Column Content */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex-1 overflow-y-auto p-2',
|
||||
!disableItemSpacing && 'space-y-2.5',
|
||||
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
|
||||
hideScrollbar &&
|
||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||
// Smooth scrolling
|
||||
'scroll-smooth',
|
||||
// Add padding at bottom if there's a footer action
|
||||
footerAction && 'pb-14',
|
||||
contentClassName
|
||||
footerAction && 'pb-14'
|
||||
)}
|
||||
ref={contentRef}
|
||||
onScroll={onScroll}
|
||||
style={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ColumnDef {
|
||||
|
||||
/**
|
||||
* Default column definitions for the list view
|
||||
* Only showing title column with full width for a cleaner, more spacious layout
|
||||
*/
|
||||
export const LIST_COLUMNS: ColumnDef[] = [
|
||||
{
|
||||
@@ -33,14 +34,6 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
||||
minWidth: 'min-w-0',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
label: 'Priority',
|
||||
sortable: true,
|
||||
width: 'w-20',
|
||||
minWidth: 'min-w-[60px]',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
export interface ListHeaderProps {
|
||||
@@ -124,7 +117,6 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.width !== 'flex-1' && 'shrink-0',
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
isSorted && 'text-foreground',
|
||||
@@ -149,7 +141,6 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
||||
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.width !== 'flex-1' && 'shrink-0',
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
column.className
|
||||
|
||||
@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-3 pr-0 py-3 gap-0',
|
||||
'flex items-center px-3 py-3 gap-2',
|
||||
getColumnWidth('title'),
|
||||
getColumnAlign('title')
|
||||
)}
|
||||
@@ -315,42 +315,6 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority column */}
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
||||
getColumnWidth('priority'),
|
||||
getColumnAlign('priority')
|
||||
)}
|
||||
data-testid={`list-row-priority-${feature.id}`}
|
||||
>
|
||||
{feature.priority ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px] font-bold text-xs',
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||
feature.priority === 3 &&
|
||||
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||
)}
|
||||
title={
|
||||
feature.priority === 1
|
||||
? 'High Priority'
|
||||
: feature.priority === 2
|
||||
? 'Medium Priority'
|
||||
: 'Low Priority'
|
||||
}
|
||||
>
|
||||
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions column */}
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { extractSummary } from '@/lib/log-parser';
|
||||
import { useAgentOutput } from '@/hooks/queries';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
@@ -29,8 +28,6 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
|
||||
projectPath?: string;
|
||||
/** Branch name for the feature worktree - used when viewing changes */
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
|
||||
@@ -43,33 +40,12 @@ export function AgentOutputModal({
|
||||
featureStatus,
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
branchName,
|
||||
}: AgentOutputModalProps) {
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
|
||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
|
||||
|
||||
// Track additional content from WebSocket events (appended to query data)
|
||||
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||
|
||||
// Use React Query for initial output loading
|
||||
const { data: initialOutput = '', isLoading } = useAgentOutput(
|
||||
resolvedProjectPath,
|
||||
featureId,
|
||||
open && !!resolvedProjectPath
|
||||
);
|
||||
|
||||
// Reset streamed content when modal opens or featureId changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStreamedContent('');
|
||||
}
|
||||
}, [open, featureId]);
|
||||
|
||||
// Combine initial output from query with streamed content from WebSocket
|
||||
const output = initialOutput + streamedContent;
|
||||
const [projectPath, setProjectPath] = useState<string>('');
|
||||
|
||||
// Extract summary from output
|
||||
const summary = useMemo(() => extractSummary(output), [output]);
|
||||
@@ -78,6 +54,7 @@ export function AgentOutputModal({
|
||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>('');
|
||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||
|
||||
// Auto-scroll to bottom when output changes
|
||||
@@ -87,6 +64,55 @@ export function AgentOutputModal({
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
// Load existing output from file
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const loadOutput = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
|
||||
if (!resolvedProjectPath) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectPathRef.current = resolvedProjectPath;
|
||||
setProjectPath(resolvedProjectPath);
|
||||
|
||||
if (isBacklogPlan) {
|
||||
setOutput('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use features API to get agent output
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
|
||||
|
||||
if (result.success) {
|
||||
setOutput(result.content || '');
|
||||
} else {
|
||||
setOutput('');
|
||||
}
|
||||
} else {
|
||||
setOutput('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load output:', error);
|
||||
setOutput('');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOutput();
|
||||
}, [open, featureId, projectPathProp, isBacklogPlan]);
|
||||
|
||||
// Listen to auto mode events and update output
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -245,8 +271,8 @@ export function AgentOutputModal({
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
// Append new content from WebSocket to streamed content
|
||||
setStreamedContent((prev) => prev + newContent);
|
||||
// Only update local state - server is the single source of truth for file writes
|
||||
setOutput((prev) => prev + newContent);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -397,17 +423,17 @@ export function AgentOutputModal({
|
||||
{!isBacklogPlan && (
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={resolvedProjectPath}
|
||||
projectPath={projectPath}
|
||||
className="shrink-0 mx-3 my-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
{resolvedProjectPath ? (
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={resolvedProjectPath}
|
||||
featureId={branchName || featureId}
|
||||
projectPath={projectPath}
|
||||
featureId={featureId}
|
||||
compact={false}
|
||||
useWorktrees={useWorktrees}
|
||||
className="border-0 rounded-lg"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,7 +17,6 @@ import { GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -55,21 +54,12 @@ export function CreatePRDialog({
|
||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||
// Branch fetching state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
// Track whether an operation completed that warrants a refresh
|
||||
const operationCompletedRef = useRef(false);
|
||||
|
||||
// Use React Query for branch fetching - only enabled when dialog is open
|
||||
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
|
||||
open ? worktree?.path : undefined,
|
||||
true // Include remote branches for PR base branch selection
|
||||
);
|
||||
|
||||
// Filter out current worktree branch from the list
|
||||
const branches = useMemo(() => {
|
||||
if (!branchesData?.branches) return [];
|
||||
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
||||
}, [branchesData?.branches, worktree?.branch]);
|
||||
|
||||
// Common state reset function to avoid duplication
|
||||
const resetState = useCallback(() => {
|
||||
setTitle('');
|
||||
@@ -82,13 +72,44 @@ export function CreatePRDialog({
|
||||
setBrowserUrl(null);
|
||||
setShowBrowserFallback(false);
|
||||
operationCompletedRef.current = false;
|
||||
setBranches([]);
|
||||
}, [defaultBaseBranch]);
|
||||
|
||||
// Fetch branches for autocomplete
|
||||
const fetchBranches = useCallback(async () => {
|
||||
if (!worktree?.path) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
return;
|
||||
}
|
||||
// Fetch both local and remote branches for PR base branch selection
|
||||
const result = await api.worktree.listBranches(worktree.path, true);
|
||||
if (result.success && result.result) {
|
||||
// Extract branch names, filtering out the current worktree branch
|
||||
const branchNames = result.result.branches
|
||||
.map((b) => b.name)
|
||||
.filter((name) => name !== worktree.branch);
|
||||
setBranches(branchNames);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - branches will default to main only
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, [worktree?.path, worktree?.branch]);
|
||||
|
||||
// Reset state when dialog opens or worktree changes
|
||||
useEffect(() => {
|
||||
// Reset all state on both open and close
|
||||
resetState();
|
||||
}, [open, worktree?.path, resetState]);
|
||||
if (open) {
|
||||
// Fetch fresh branches when dialog opens
|
||||
fetchBranches();
|
||||
}
|
||||
}, [open, worktree?.path, resetState, fetchBranches]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type DependencyLinkType = 'parent' | 'child';
|
||||
|
||||
interface DependencyLinkDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
draggedFeature: Feature | null;
|
||||
targetFeature: Feature | null;
|
||||
onLink: (linkType: DependencyLinkType) => void;
|
||||
}
|
||||
|
||||
export function DependencyLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
draggedFeature,
|
||||
targetFeature,
|
||||
onLink,
|
||||
}: DependencyLinkDialogProps) {
|
||||
if (!draggedFeature || !targetFeature) return null;
|
||||
|
||||
// Check if a dependency relationship already exists
|
||||
const draggedDependsOnTarget =
|
||||
Array.isArray(draggedFeature.dependencies) &&
|
||||
draggedFeature.dependencies.includes(targetFeature.id);
|
||||
const targetDependsOnDragged =
|
||||
Array.isArray(targetFeature.dependencies) &&
|
||||
targetFeature.dependencies.includes(draggedFeature.id);
|
||||
const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent data-testid="dependency-link-dialog" className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Link Features
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a dependency relationship between these features.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Dragged feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{draggedFeature.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{draggedFeature.category}</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicating direction */}
|
||||
<div className="flex justify-center">
|
||||
<ArrowDown className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Target feature */}
|
||||
<div className="p-3 rounded-lg border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
|
||||
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||
{targetFeature.description}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{targetFeature.category}</div>
|
||||
</div>
|
||||
|
||||
{/* Existing link warning */}
|
||||
{existingLink && (
|
||||
<div className="p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{draggedDependsOnTarget
|
||||
? 'The dragged feature already depends on the target feature.'
|
||||
: 'The target feature already depends on the dragged feature.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:!justify-start">
|
||||
{/* Set as Parent - top */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onLink('child')}
|
||||
disabled={draggedDependsOnTarget}
|
||||
className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
|
||||
title={
|
||||
draggedDependsOnTarget
|
||||
? 'This would create a circular dependency'
|
||||
: 'Make target feature depend on dragged (dragged is parent)'
|
||||
}
|
||||
data-testid="link-as-parent"
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 mr-2" />
|
||||
Set as Parent
|
||||
<span className="text-xs ml-1 opacity-70">(target depends on this)</span>
|
||||
</Button>
|
||||
{/* Set as Child - middle */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => onLink('parent')}
|
||||
disabled={targetDependsOnDragged}
|
||||
className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
|
||||
title={
|
||||
targetDependsOnDragged
|
||||
? 'This would create a circular dependency'
|
||||
: 'Make dragged feature depend on target (target is parent)'
|
||||
}
|
||||
data-testid="link-as-child"
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 mr-2" />
|
||||
Set as Child
|
||||
<span className="text-xs ml-1 opacity-70">(depends on target)</span>
|
||||
</Button>
|
||||
{/* Cancel - bottom */}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full">
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,7 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
|
||||
export { CompletedFeaturesModal } from './completed-features-modal';
|
||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||
export { MassEditDialog } from './mass-edit-dialog';
|
||||
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||
|
||||
@@ -128,7 +128,6 @@ export function MassEditDialog({
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [providerId, setProviderId] = useState<string | undefined>(undefined);
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
const [priority, setPriority] = useState(2);
|
||||
@@ -163,7 +162,6 @@ export function MassEditDialog({
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setProviderId(undefined); // Features don't store providerId, but we track it after selection
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
setPriority(getInitialValue(selectedFeatures, 'priority', 2));
|
||||
@@ -228,11 +226,10 @@ export function MassEditDialog({
|
||||
Select a specific model configuration
|
||||
</p>
|
||||
<PhaseModelSelector
|
||||
value={{ model, thinkingLevel, providerId }}
|
||||
value={{ model, thinkingLevel }}
|
||||
onChange={(entry: PhaseModelEntry) => {
|
||||
setModel(entry.model as ModelAlias);
|
||||
setThinkingLevel(entry.thinkingLevel || 'none');
|
||||
setProviderId(entry.providerId);
|
||||
// Auto-enable model and thinking level for apply state
|
||||
setApplyState((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -8,81 +8,58 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
export type { MergeConflictInfo } from '../worktree-panel/types';
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface MergeWorktreeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectPath: string;
|
||||
worktree: WorktreeInfo | null;
|
||||
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
||||
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
onMerged: (mergedWorktree: WorktreeInfo) => void;
|
||||
/** Number of features assigned to this worktree's branch */
|
||||
affectedFeatureCount?: number;
|
||||
}
|
||||
|
||||
type DialogStep = 'confirm' | 'verify';
|
||||
|
||||
export function MergeWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectPath,
|
||||
worktree,
|
||||
onMerged,
|
||||
onCreateConflictResolutionFeature,
|
||||
affectedFeatureCount = 0,
|
||||
}: MergeWorktreeDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [targetBranch, setTargetBranch] = useState('main');
|
||||
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
|
||||
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||
const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
|
||||
const [mergeConflict, setMergeConflict] = useState<MergeConflictInfo | null>(null);
|
||||
|
||||
// Fetch available branches when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree && projectPath) {
|
||||
setLoadingBranches(true);
|
||||
const api = getElectronAPI();
|
||||
if (api?.worktree?.listBranches) {
|
||||
api.worktree
|
||||
.listBranches(projectPath, false)
|
||||
.then((result) => {
|
||||
if (result.success && result.result?.branches) {
|
||||
// Filter out the source branch (can't merge into itself) and remote branches
|
||||
const branches = result.result.branches
|
||||
.filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
|
||||
.map((b: BranchInfo) => b.name);
|
||||
setAvailableBranches(branches);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch branches:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingBranches(false);
|
||||
});
|
||||
} else {
|
||||
setLoadingBranches(false);
|
||||
}
|
||||
}
|
||||
}, [open, worktree, projectPath]);
|
||||
const [step, setStep] = useState<DialogStep>('confirm');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsLoading(false);
|
||||
setTargetBranch('main');
|
||||
setDeleteWorktreeAndBranch(false);
|
||||
setMergeConflict(null);
|
||||
setStep('confirm');
|
||||
setConfirmText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleProceedToVerify = () => {
|
||||
setStep('verify');
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
@@ -94,151 +71,96 @@ export function MergeWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass branchName, worktreePath, targetBranch, and options to the API
|
||||
const result = await api.worktree.mergeFeature(
|
||||
projectPath,
|
||||
worktree.branch,
|
||||
worktree.path,
|
||||
targetBranch,
|
||||
{ deleteWorktreeAndBranch }
|
||||
);
|
||||
// Pass branchName and worktreePath directly to the API
|
||||
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
|
||||
|
||||
if (result.success) {
|
||||
const description = deleteWorktreeAndBranch
|
||||
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
||||
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
||||
toast.success(`Branch merged to ${targetBranch}`, { description });
|
||||
onMerged(worktree, deleteWorktreeAndBranch);
|
||||
toast.success('Branch merged to main', {
|
||||
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
|
||||
});
|
||||
onMerged(worktree);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// Check if the error indicates merge conflicts
|
||||
const errorMessage = result.error || '';
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
// Set merge conflict state to show the conflict resolution UI
|
||||
setMergeConflict({
|
||||
sourceBranch: worktree.branch,
|
||||
targetBranch: targetBranch,
|
||||
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
toast.error('Failed to merge branch', {
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
// Check if the error indicates merge conflicts
|
||||
const hasConflicts =
|
||||
errorMessage.toLowerCase().includes('conflict') ||
|
||||
errorMessage.toLowerCase().includes('merge failed') ||
|
||||
errorMessage.includes('CONFLICT');
|
||||
|
||||
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||
setMergeConflict({
|
||||
sourceBranch: worktree.branch,
|
||||
targetBranch: targetBranch,
|
||||
targetWorktreePath: projectPath,
|
||||
});
|
||||
toast.error('Merge conflicts detected', {
|
||||
description: 'The merge has conflicts that need to be resolved manually.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to merge branch', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
toast.error('Failed to merge branch', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateConflictResolutionFeature = () => {
|
||||
if (mergeConflict && onCreateConflictResolutionFeature) {
|
||||
onCreateConflictResolutionFeature(mergeConflict);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
// Show conflict resolution UI if there are merge conflicts
|
||||
if (mergeConflict) {
|
||||
const confirmationWord = 'merge';
|
||||
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
|
||||
|
||||
// First step: Show what will happen and ask for confirmation
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Merge Conflicts Detected
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge to Main
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
There are conflicts when merging{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.sourceBranch}
|
||||
</code>{' '}
|
||||
into{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>
|
||||
.
|
||||
Merge branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
|
||||
main?
|
||||
</span>
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
The merge could not be completed automatically. You can create a feature task to
|
||||
resolve the conflicts in the{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>{' '}
|
||||
branch.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a high-priority feature task that will:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
|
||||
<li>
|
||||
Resolve merge conflicts in the{' '}
|
||||
<code className="font-mono bg-muted px-0.5 rounded">
|
||||
{mergeConflict.targetBranch}
|
||||
</code>{' '}
|
||||
branch
|
||||
</li>
|
||||
<li>Ensure the code compiles and tests pass</li>
|
||||
<li>Complete the merge automatically</li>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
This will:
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Merge the branch into the main branch</li>
|
||||
<li>Remove the worktree directory</li>
|
||||
<li>Delete the branch</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{affectedFeatureCount > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-blue-500 text-sm">
|
||||
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
|
||||
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
|
||||
be unassigned after merge.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConflictResolutionFeature}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
onClick={handleProceedToVerify}
|
||||
disabled={worktree.hasChanges}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
Create Resolve Conflicts Feature
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -246,86 +168,52 @@ export function MergeWorktreeDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// Second step: Type confirmation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-green-600" />
|
||||
Merge Branch
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Confirm Merge
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<span className="block">
|
||||
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||
into:
|
||||
</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-branch" className="text-sm text-foreground">
|
||||
Target Branch
|
||||
</Label>
|
||||
{loadingBranches ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
Loading branches...
|
||||
</div>
|
||||
) : (
|
||||
<BranchAutocomplete
|
||||
value={targetBranch}
|
||||
onChange={setTargetBranch}
|
||||
branches={availableBranches}
|
||||
placeholder="Select target branch..."
|
||||
data-testid="merge-target-branch"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-600 dark:text-orange-400 text-sm">
|
||||
This action cannot be undone. The branch{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
|
||||
permanently deleted after merging.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-yellow-500 text-sm">
|
||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||
commit or discard them before merging.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
|
||||
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
|
||||
confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-merge"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={confirmationWord}
|
||||
disabled={isLoading}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id="delete-worktree-branch"
|
||||
checked={deleteWorktreeAndBranch}
|
||||
onCheckedChange={(checked) => setDeleteWorktreeAndBranch(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="delete-worktree-branch"
|
||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||
Delete worktree and branch after merging
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{deleteWorktreeAndBranch && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-orange-500 text-sm">
|
||||
The worktree and branch will be permanently deleted. Any features assigned to this
|
||||
branch will be unassigned.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
|
||||
disabled={isLoading || !isConfirmValid}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -335,8 +223,8 @@ export function MergeWorktreeDialog({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Merge
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Merge to Main
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface RemoteBranch {
|
||||
name: string;
|
||||
fullRef: string;
|
||||
}
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches: RemoteBranch[];
|
||||
}
|
||||
|
||||
const logger = createLogger('PullResolveConflictsDialog');
|
||||
|
||||
interface PullResolveConflictsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
|
||||
}
|
||||
|
||||
export function PullResolveConflictsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
}: PullResolveConflictsDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setSelectedBranch('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-select default remote and branch when remotes are loaded
|
||||
useEffect(() => {
|
||||
if (remotes.length > 0 && !selectedRemote) {
|
||||
// Default to 'origin' if available, otherwise first remote
|
||||
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
|
||||
// Try to select a matching branch name or default to main/master
|
||||
if (defaultRemote.branches.length > 0 && worktree) {
|
||||
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
|
||||
const mainBranch = defaultRemote.branches.find(
|
||||
(b) => b.name === 'main' || b.name === 'master'
|
||||
);
|
||||
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
|
||||
setSelectedBranch(defaultBranch.fullRef);
|
||||
}
|
||||
}
|
||||
}, [remotes, selectedRemote, worktree]);
|
||||
|
||||
// Update selected branch when remote changes
|
||||
useEffect(() => {
|
||||
if (selectedRemote && remotes.length > 0 && worktree) {
|
||||
const remote = remotes.find((r) => r.name === selectedRemote);
|
||||
if (remote && remote.branches.length > 0) {
|
||||
// Try to select a matching branch name or default to main/master
|
||||
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
|
||||
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
|
||||
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
|
||||
setSelectedBranch(defaultBranch.fullRef);
|
||||
} else {
|
||||
setSelectedBranch('');
|
||||
}
|
||||
}
|
||||
}, [selectedRemote, remotes, worktree]);
|
||||
|
||||
const fetchRemotes = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setRemotes(result.result.remotes);
|
||||
if (result.result.remotes.length === 0) {
|
||||
setError('No remotes found in this repository');
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError('Failed to fetch remotes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setRemotes(result.result.remotes);
|
||||
toast.success('Remotes refreshed');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to refresh remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh remotes:', err);
|
||||
toast.error('Failed to refresh remotes');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!worktree || !selectedBranch) return;
|
||||
onConfirm(worktree, selectedBranch);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
||||
const branches = selectedRemoteData?.branches || [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Pull & Resolve Conflicts
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a remote branch to pull from and resolve conflicts with{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchRemotes}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Remote</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select">
|
||||
<SelectValue placeholder="Select a remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="branch-select">Branch</Label>
|
||||
<Select
|
||||
value={selectedBranch}
|
||||
onValueChange={setSelectedBranch}
|
||||
disabled={!selectedRemote || branches.length === 0}
|
||||
>
|
||||
<SelectTrigger id="branch-select">
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{selectedRemote} branches</SelectLabel>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch.fullRef} value={branch.fullRef}>
|
||||
{branch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRemote && branches.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a feature task to pull from{' '}
|
||||
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
|
||||
any merge conflicts.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedBranch || isLoading}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitMerge className="w-4 h-4 mr-2" />
|
||||
Pull & Resolve
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const logger = createLogger('PushToRemoteDialog');
|
||||
|
||||
interface PushToRemoteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
|
||||
}
|
||||
|
||||
export function PushToRemoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
onConfirm,
|
||||
}: PushToRemoteDialogProps) {
|
||||
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
fetchRemotes();
|
||||
}
|
||||
}, [open, worktree]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedRemote('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-select default remote when remotes are loaded
|
||||
useEffect(() => {
|
||||
if (remotes.length > 0 && !selectedRemote) {
|
||||
// Default to 'origin' if available, otherwise first remote
|
||||
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
|
||||
setSelectedRemote(defaultRemote.name);
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
const fetchRemotes = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
// Extract just the remote info (name and URL), not the branches
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
if (remoteInfos.length === 0) {
|
||||
setError('No remotes found in this repository. Please add a remote first.');
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to fetch remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch remotes:', err);
|
||||
setError('Failed to fetch remotes');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!worktree) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
}));
|
||||
setRemotes(remoteInfos);
|
||||
toast.success('Remotes refreshed');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to refresh remotes');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh remotes:', err);
|
||||
toast.error('Failed to refresh remotes');
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!worktree || !selectedRemote) return;
|
||||
onConfirm(worktree, selectedRemote);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-primary" />
|
||||
Push New Branch to Remote
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
new
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Push{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{worktree?.branch || 'current branch'}
|
||||
</span>{' '}
|
||||
to a remote repository for the first time.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchRemotes}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="remote-select">Select Remote</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size="xs" className="mr-1" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||
<SelectTrigger id="remote-select">
|
||||
<SelectValue placeholder="Select a remote" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{remote.url}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRemote && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a new remote branch{' '}
|
||||
<span className="font-mono text-foreground">
|
||||
{selectedRemote}/{worktree?.branch}
|
||||
</span>{' '}
|
||||
and set up tracking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Push to {selectedRemote || 'Remote'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
interface ViewWorktreeChangesDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function ViewWorktreeChangesDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
projectPath,
|
||||
}: ViewWorktreeChangesDialogProps) {
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
View Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Changes in the{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||
{worktree.changedFilesCount !== undefined && worktree.changedFilesCount > 0 && (
|
||||
<span className="ml-1">
|
||||
({worktree.changedFilesCount} file
|
||||
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
featureId={worktree.branch}
|
||||
useWorktrees={true}
|
||||
compact={false}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
@@ -23,8 +23,7 @@ interface HeaderMobileMenuProps {
|
||||
// Auto mode
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
skipVerificationInAutoMode: boolean;
|
||||
onSkipVerificationChange: (value: boolean) => void;
|
||||
onOpenAutoModeSettings: () => void;
|
||||
// Plan button
|
||||
onOpenPlanDialog: () => void;
|
||||
// Usage bar visibility
|
||||
@@ -42,8 +41,7 @@ export function HeaderMobileMenu({
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
skipVerificationInAutoMode,
|
||||
onSkipVerificationChange,
|
||||
onOpenAutoModeSettings,
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
@@ -68,23 +66,22 @@ export function HeaderMobileMenu({
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Section */}
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
@@ -92,51 +89,17 @@ export function HeaderMobileMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Skip Verification Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
|
||||
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
|
||||
data-testid="mobile-skip-verification-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FastForward className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Skip Verification</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-skip-verification-toggle"
|
||||
checked={skipVerificationInAutoMode}
|
||||
onCheckedChange={onSkipVerificationChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-skip-verification-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 pl-9 border-t border-border/30"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +122,32 @@ export function HeaderMobileMenu({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getElectronAPI } from '@/lib/electron';
|
||||
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||
import { truncateDescription } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
@@ -92,14 +91,9 @@ export function useBoardActions({
|
||||
skipVerificationInAutoMode,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
// React Query mutations for feature operations
|
||||
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
|
||||
const resumeFeatureMutation = useResumeFeature(currentProject?.path ?? '');
|
||||
|
||||
// Worktrees are created when adding/editing features with a branch name
|
||||
// This ensures the worktree exists before the feature starts execution
|
||||
|
||||
@@ -124,15 +118,14 @@ export function useBoardActions({
|
||||
const workMode = featureData.workMode || 'current';
|
||||
|
||||
// Determine final branch name based on work mode:
|
||||
// - 'current': Use current worktree's branch (or undefined if on main)
|
||||
// - 'current': No branch name, work on current branch (no worktree)
|
||||
// - 'auto': Auto-generate branch name based on current branch
|
||||
// - 'custom': Use the provided branch name
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
// Work directly on current branch - use the current worktree's branch if not on main
|
||||
// This ensures features created on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
// No worktree isolation - work directly on current branch
|
||||
finalBranchName = 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
|
||||
@@ -219,7 +212,7 @@ export function useBoardActions({
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
api.features
|
||||
.generateTitle(featureData.description, projectPath ?? undefined)
|
||||
.generateTitle(featureData.description)
|
||||
.then((result) => {
|
||||
if (result.success && result.title) {
|
||||
const titleUpdates = {
|
||||
@@ -252,12 +245,10 @@ export function useBoardActions({
|
||||
updateFeature,
|
||||
saveCategory,
|
||||
currentProject,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -291,9 +282,7 @@ export function useBoardActions({
|
||||
let finalBranchName: string | undefined;
|
||||
|
||||
if (workMode === 'current') {
|
||||
// Work directly on current branch - use the current worktree's branch if not on main
|
||||
// This ensures features updated on a non-main worktree are associated with that worktree
|
||||
finalBranchName = currentWorktreeBranch || undefined;
|
||||
finalBranchName = 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
|
||||
@@ -408,7 +397,6 @@ export function useBoardActions({
|
||||
onWorktreeCreated,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
currentWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -486,30 +474,10 @@ export function useBoardActions({
|
||||
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
if (!canStartInWorktree) {
|
||||
const worktreeDesc = featureBranchName
|
||||
? `worktree "${featureBranchName}"`
|
||||
: 'main worktree';
|
||||
if (!autoMode.canStartNewTask) {
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
||||
featureMaxConcurrency > 1 ? 's' : ''
|
||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
||||
autoMode.maxConcurrency > 1 ? 's' : ''
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
return false;
|
||||
@@ -573,18 +541,34 @@ export function useBoardActions({
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
verifyFeatureMutation.mutate(feature.id);
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
logger.error('Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Feature verification started successfully');
|
||||
} else {
|
||||
logger.error('Failed to verify feature:', result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error verifying feature:', error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, verifyFeatureMutation]
|
||||
[currentProject, loadFeatures]
|
||||
);
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
@@ -594,9 +578,40 @@ export function useBoardActions({
|
||||
logger.error('No current project');
|
||||
return;
|
||||
}
|
||||
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) {
|
||||
logger.error('Auto mode API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Calling resumeFeature API...', {
|
||||
projectPath: currentProject.path,
|
||||
featureId: feature.id,
|
||||
useWorktrees,
|
||||
});
|
||||
|
||||
const result = await api.autoMode.resumeFeature(
|
||||
currentProject.path,
|
||||
feature.id,
|
||||
useWorktrees
|
||||
);
|
||||
|
||||
logger.info('resumeFeature result:', result);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Feature resume started successfully');
|
||||
} else {
|
||||
logger.error('Failed to resume feature:', result.error);
|
||||
await loadFeatures();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error resuming feature:', error);
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, resumeFeatureMutation, useWorktrees]
|
||||
[currentProject, loadFeatures, useWorktrees]
|
||||
);
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
createFeatureMap,
|
||||
getBlockingDependenciesFromMap,
|
||||
resolveDependencies,
|
||||
} from '@automaker/dependency-resolver';
|
||||
import { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
|
||||
type ColumnId = Feature['status'];
|
||||
|
||||
@@ -36,8 +32,6 @@ export function useBoardColumnFeatures({
|
||||
verified: [],
|
||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||
};
|
||||
const featureMap = createFeatureMap(features);
|
||||
const runningTaskIds = new Set(runningAutoTasks);
|
||||
|
||||
// Filter features by search query (case-insensitive)
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
@@ -61,7 +55,7 @@ export function useBoardColumnFeatures({
|
||||
|
||||
filteredFeatures.forEach((f) => {
|
||||
// If feature has a running agent, always show it in "in_progress"
|
||||
const isRunning = runningTaskIds.has(f.id);
|
||||
const isRunning = runningAutoTasks.includes(f.id);
|
||||
|
||||
// Check if feature matches the current worktree by branchName
|
||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||
@@ -103,25 +97,8 @@ export function useBoardColumnFeatures({
|
||||
// Historically, we forced "running" features into in_progress so they never disappeared
|
||||
// during stale reload windows. With pipelines, a feature can legitimately be running while
|
||||
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
|
||||
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
|
||||
// it's already running for the current worktree. However, we still need to check matchesWorktree
|
||||
// to ensure the feature's branchName matches the current worktree's branch.
|
||||
if (isRunning) {
|
||||
// If feature is running but doesn't match worktree, it might be a timing issue where
|
||||
// the feature was started for a different worktree. Still show it if it's running to
|
||||
// prevent disappearing features, but log a warning.
|
||||
if (!matchesWorktree) {
|
||||
// This can happen if:
|
||||
// 1. Feature was started for a different worktree (bug)
|
||||
// 2. Timing issue where branchName hasn't been set yet
|
||||
// 3. User switched worktrees while feature was starting
|
||||
// Still show it in in_progress to prevent it from disappearing
|
||||
console.debug(
|
||||
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
|
||||
);
|
||||
map.in_progress.push(f);
|
||||
return;
|
||||
}
|
||||
if (!matchesWorktree) return;
|
||||
|
||||
if (status.startsWith('pipeline_')) {
|
||||
if (!map[status]) map[status] = [];
|
||||
@@ -174,6 +151,7 @@ export function useBoardColumnFeatures({
|
||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||
|
||||
// Get all features to check blocking dependencies against
|
||||
const allFeatures = features;
|
||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||
|
||||
// Sort blocked features to the end of the backlog
|
||||
@@ -183,7 +161,7 @@ export function useBoardColumnFeatures({
|
||||
const blocked: Feature[] = [];
|
||||
|
||||
for (const f of orderedFeatures) {
|
||||
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
|
||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||
blocked.push(f);
|
||||
} else {
|
||||
unblocked.push(f);
|
||||
|
||||
@@ -8,11 +8,6 @@ import { COLUMNS, ColumnId } from '../constants';
|
||||
|
||||
const logger = createLogger('BoardDragDrop');
|
||||
|
||||
export interface PendingDependencyLink {
|
||||
draggedFeature: Feature;
|
||||
targetFeature: Feature;
|
||||
}
|
||||
|
||||
interface UseBoardDragDropProps {
|
||||
features: Feature[];
|
||||
currentProject: { path: string; id: string } | null;
|
||||
@@ -29,10 +24,7 @@ export function useBoardDragDrop({
|
||||
handleStartImplementation,
|
||||
}: UseBoardDragDropProps) {
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
|
||||
null
|
||||
);
|
||||
const { moveFeature, updateFeature } = useAppStore();
|
||||
const { moveFeature } = useAppStore();
|
||||
|
||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||
// at execution time based on feature.branchName
|
||||
@@ -48,11 +40,6 @@ export function useBoardDragDrop({
|
||||
[features]
|
||||
);
|
||||
|
||||
// Clear pending dependency link
|
||||
const clearPendingDependencyLink = useCallback(() => {
|
||||
setPendingDependencyLink(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -70,92 +57,6 @@ export function useBoardDragDrop({
|
||||
// Check if this is a running task (non-skipTests, TDD)
|
||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||
|
||||
// Check if dropped on another card (for creating dependency links)
|
||||
if (overId.startsWith('card-drop-')) {
|
||||
const cardData = over.data.current as {
|
||||
type: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (cardData?.type === 'card') {
|
||||
const targetFeatureId = cardData.featureId;
|
||||
|
||||
// Don't link to self
|
||||
if (targetFeatureId === featureId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||
if (!targetFeature) return;
|
||||
|
||||
// Only allow linking backlog features (both must be in backlog)
|
||||
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
||||
toast.error('Cannot link features', {
|
||||
description: 'Both features must be in the backlog to create a dependency link.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set pending dependency link to trigger dialog
|
||||
setPendingDependencyLink({
|
||||
draggedFeature,
|
||||
targetFeature,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if dropped on a worktree tab
|
||||
if (overId.startsWith('worktree-drop-')) {
|
||||
// Handle dropping on a worktree - change the feature's branchName
|
||||
const worktreeData = over.data.current as {
|
||||
type: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
isMain: boolean;
|
||||
};
|
||||
|
||||
if (worktreeData?.type === 'worktree') {
|
||||
// Don't allow moving running tasks to a different worktree
|
||||
if (isRunningTask) {
|
||||
logger.debug('Cannot move running feature to different worktree');
|
||||
toast.error('Cannot move feature', {
|
||||
description: 'This feature is currently running and cannot be moved.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetBranch = worktreeData.branch;
|
||||
const currentBranch = draggedFeature.branchName;
|
||||
|
||||
// For main worktree, set branchName to null to indicate it should use main
|
||||
// (must use null not undefined so it serializes to JSON for the API call)
|
||||
// For other worktrees, set branchName to the target branch
|
||||
const newBranchName = worktreeData.isMain ? null : targetBranch;
|
||||
|
||||
// If already on the same branch, nothing to do
|
||||
// For main worktree: feature with null/undefined branchName is already on main
|
||||
// For other worktrees: compare branch names directly
|
||||
const isAlreadyOnTarget = worktreeData.isMain
|
||||
? !currentBranch // null or undefined means already on main
|
||||
: currentBranch === targetBranch;
|
||||
|
||||
if (isAlreadyOnTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update feature's branchName
|
||||
updateFeature(featureId, { branchName: newBranchName });
|
||||
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||
|
||||
const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
|
||||
toast.success('Feature moved to branch', {
|
||||
description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if dragging is allowed based on status and skipTests
|
||||
// - Backlog items can always be dragged
|
||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||
@@ -304,21 +205,12 @@ export function useBoardDragDrop({
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
features,
|
||||
runningAutoTasks,
|
||||
moveFeature,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleStartImplementation,
|
||||
]
|
||||
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
|
||||
);
|
||||
|
||||
return {
|
||||
activeFeature,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
pendingDependencyLink,
|
||||
clearPendingDependencyLink,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
const logger = createLogger('BoardEffects');
|
||||
@@ -64,8 +65,37 @@ export function useBoardEffects({
|
||||
};
|
||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||
|
||||
// Note: Running tasks sync is now handled by useAutoMode hook in BoardView
|
||||
// which correctly handles worktree/branch scoping.
|
||||
// Sync running tasks from electron backend on mount
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncRunningTasks = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const status = await api.autoMode.status(currentProject.path);
|
||||
if (status.success) {
|
||||
const projectId = currentProject.id;
|
||||
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
||||
|
||||
if (status.runningFeatures) {
|
||||
logger.info('Syncing running tasks from backend:', status.runningFeatures);
|
||||
|
||||
clearRunningTasks(projectId);
|
||||
|
||||
status.runningFeatures.forEach((featureId: string) => {
|
||||
addRunningTask(projectId, featureId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync running tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncRunningTasks();
|
||||
}, [currentProject]);
|
||||
|
||||
// Check which features have context files
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
/**
|
||||
* Board Features Hook
|
||||
*
|
||||
* React Query-based hook for managing features on the board view.
|
||||
* Handles feature loading, categories, and auto-mode event notifications.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useFeatures } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
const logger = createLogger('BoardFeatures');
|
||||
|
||||
@@ -21,15 +11,105 @@ interface UseBoardFeaturesProps {
|
||||
}
|
||||
|
||||
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { features, setFeatures } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||
|
||||
// Use React Query for features
|
||||
const {
|
||||
data: features = [],
|
||||
isLoading,
|
||||
refetch: loadFeatures,
|
||||
} = useFeatures(currentProject?.path);
|
||||
// Track previous project path to detect project switches
|
||||
const prevProjectPathRef = useRef<string | null>(null);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const isSwitchingProjectRef = useRef(false);
|
||||
|
||||
// Load features using features API
|
||||
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const currentPath = currentProject.path;
|
||||
const previousPath = prevProjectPathRef.current;
|
||||
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
|
||||
|
||||
// Get cached features from store (without adding to dependencies)
|
||||
const cachedFeatures = useAppStore.getState().features;
|
||||
|
||||
// If project switched, mark it but don't clear features yet
|
||||
// We'll clear after successful API load to prevent data loss
|
||||
if (isProjectSwitch) {
|
||||
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
|
||||
isSwitchingProjectRef.current = true;
|
||||
isInitialLoadRef.current = true;
|
||||
}
|
||||
|
||||
// Update the ref to track current project
|
||||
prevProjectPathRef.current = currentPath;
|
||||
|
||||
// Only show loading spinner on initial load to prevent board flash during reloads
|
||||
if (isInitialLoadRef.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
logger.error('Features API not available');
|
||||
// Keep cached features if API is unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.features.getAll(currentProject.path);
|
||||
|
||||
if (result.success && result.features) {
|
||||
const featuresWithIds = result.features.map((f: any, index: number) => ({
|
||||
...f,
|
||||
id: f.id || `feature-${index}-${Date.now()}`,
|
||||
status: f.status || 'backlog',
|
||||
startedAt: f.startedAt, // Preserve startedAt timestamp
|
||||
// Ensure model and thinkingLevel are set for backward compatibility
|
||||
model: f.model || 'opus',
|
||||
thinkingLevel: f.thinkingLevel || 'none',
|
||||
}));
|
||||
// Successfully loaded features - now safe to set them
|
||||
setFeatures(featuresWithIds);
|
||||
|
||||
// Only clear categories on project switch AFTER successful load
|
||||
if (isProjectSwitch) {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
|
||||
// Check for interrupted features and resume them
|
||||
// This handles server restarts where features were in pipeline steps
|
||||
if (api.autoMode?.resumeInterrupted) {
|
||||
try {
|
||||
await api.autoMode.resumeInterrupted(currentProject.path);
|
||||
logger.info('Checked for interrupted features');
|
||||
} catch (resumeError) {
|
||||
logger.warn('Failed to check for interrupted features:', resumeError);
|
||||
}
|
||||
}
|
||||
} else if (!result.success && result.error) {
|
||||
logger.error('API returned error:', result.error);
|
||||
// If it's a new project or the error indicates no features found,
|
||||
// that's expected - start with empty array
|
||||
if (isProjectSwitch) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
// Otherwise keep cached features
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load features:', error);
|
||||
// On error, keep existing cached features for the current project
|
||||
// Only clear on project switch if we have no features from server
|
||||
if (isProjectSwitch && cachedFeatures.length === 0) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadRef.current = false;
|
||||
isSwitchingProjectRef.current = false;
|
||||
}
|
||||
}, [currentProject, setFeatures]);
|
||||
|
||||
// Load persisted categories from file
|
||||
const loadCategories = useCallback(async () => {
|
||||
@@ -45,12 +125,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
setPersistedCategories(parsed);
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error('Failed to load categories:', error);
|
||||
// If file doesn't exist, ensure categories are cleared
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
}, [currentProject, loadFeatures]);
|
||||
}, [currentProject]);
|
||||
|
||||
// Save a new category to the persisted categories file
|
||||
const saveCategory = useCallback(
|
||||
@@ -59,17 +142,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Read existing categories
|
||||
let categories: string[] = [...persistedCategories];
|
||||
|
||||
// Add new category if it doesn't exist
|
||||
if (!categories.includes(category)) {
|
||||
categories.push(category);
|
||||
categories.sort();
|
||||
categories.sort(); // Keep sorted
|
||||
|
||||
// Write back to file
|
||||
await api.writeFile(
|
||||
`${currentProject.path}/.automaker/categories.json`,
|
||||
JSON.stringify(categories, null, 2)
|
||||
);
|
||||
|
||||
// Update state
|
||||
setPersistedCategories(categories);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -79,38 +167,42 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
[currentProject, persistedCategories]
|
||||
);
|
||||
|
||||
// Subscribe to auto mode events for notifications (ding sound, toasts)
|
||||
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
|
||||
// Subscribe to spec regeneration complete events to refresh kanban board
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.specRegeneration) return;
|
||||
|
||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
||||
// Refresh the kanban board when spec regeneration completes for the current project
|
||||
if (
|
||||
event.type === 'spec_regeneration_complete' &&
|
||||
currentProject &&
|
||||
event.projectPath === currentProject.path
|
||||
) {
|
||||
logger.info('Spec regeneration complete, refreshing features');
|
||||
loadFeatures();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [currentProject, loadFeatures]);
|
||||
|
||||
// Listen for auto mode feature completion and errors to reload features
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode || !currentProject) return;
|
||||
|
||||
const { removeRunningTask } = useAppStore.getState();
|
||||
const projectId = currentProject.id;
|
||||
const projectPath = currentProject.path;
|
||||
|
||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||
// Check if event is for the current project by matching projectPath
|
||||
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
|
||||
if (eventProjectPath && eventProjectPath !== projectPath) {
|
||||
// Event is for a different project, ignore it
|
||||
logger.debug(
|
||||
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use event's projectPath or projectId if available, otherwise use current project
|
||||
// Board view only reacts to events for the currently selected project
|
||||
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
|
||||
|
||||
if (event.type === 'auto_mode_feature_start') {
|
||||
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
|
||||
logger.info(
|
||||
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
|
||||
);
|
||||
loadFeatures();
|
||||
} else if (event.type === 'auto_mode_feature_complete') {
|
||||
if (event.type === 'auto_mode_feature_complete') {
|
||||
// Reload features when a feature is completed
|
||||
logger.info('Feature completed, reloading features...');
|
||||
loadFeatures();
|
||||
@@ -120,15 +212,28 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
const audio = new Audio('/sounds/ding.mp3');
|
||||
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
|
||||
}
|
||||
} else if (event.type === 'plan_approval_required') {
|
||||
// Reload features when plan is generated and requires approval
|
||||
// This ensures the feature card shows the "Approve Plan" button
|
||||
logger.info('Plan approval required, reloading features...');
|
||||
loadFeatures();
|
||||
} else if (event.type === 'pipeline_step_started') {
|
||||
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
|
||||
// Reload so the card moves into the correct pipeline column immediately.
|
||||
logger.info('Pipeline step started, reloading features...');
|
||||
loadFeatures();
|
||||
} else if (event.type === 'auto_mode_error') {
|
||||
// Remove from running tasks
|
||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
||||
logger.info('Feature error, reloading features...', event.error);
|
||||
|
||||
// Remove from running tasks so it moves to the correct column
|
||||
if (event.featureId) {
|
||||
const eventBranchName =
|
||||
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||
removeRunningTask(eventProjectId, event.featureId);
|
||||
}
|
||||
|
||||
// Show error toast
|
||||
loadFeatures();
|
||||
|
||||
// Check for authentication errors and show a more helpful message
|
||||
const isAuthError =
|
||||
event.errorType === 'authentication' ||
|
||||
(event.error &&
|
||||
@@ -150,46 +255,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentProject]);
|
||||
}, [loadFeatures, currentProject]);
|
||||
|
||||
// Check for interrupted features on mount
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
loadFeatures();
|
||||
}, [loadFeatures]);
|
||||
|
||||
const checkInterrupted = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (api.autoMode?.resumeInterrupted) {
|
||||
try {
|
||||
await api.autoMode.resumeInterrupted(currentProject.path);
|
||||
logger.info('Checked for interrupted features');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check for interrupted features:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkInterrupted();
|
||||
}, [currentProject]);
|
||||
|
||||
// Load persisted categories on mount/project change
|
||||
// Load persisted categories on mount
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
// Clear categories when project changes
|
||||
useEffect(() => {
|
||||
setPersistedCategories([]);
|
||||
}, [currentProject?.path]);
|
||||
|
||||
return {
|
||||
features,
|
||||
isLoading,
|
||||
persistedCategories,
|
||||
loadFeatures: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
|
||||
});
|
||||
},
|
||||
loadFeatures,
|
||||
loadCategories,
|
||||
saveCategory,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
const logger = createLogger('BoardPersistence');
|
||||
|
||||
@@ -14,7 +12,6 @@ interface UseBoardPersistenceProps {
|
||||
|
||||
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
||||
const { updateFeature } = useAppStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Persist feature update to API (replaces saveFeatures)
|
||||
const persistFeatureUpdate = useCallback(
|
||||
@@ -48,21 +45,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
feature: result.feature,
|
||||
});
|
||||
if (result.success && result.feature) {
|
||||
const updatedFeature = result.feature;
|
||||
updateFeature(updatedFeature.id, updatedFeature);
|
||||
queryClient.setQueryData<Feature[]>(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(features) => {
|
||||
if (!features) return features;
|
||||
return features.map((feature) =>
|
||||
feature.id === updatedFeature.id ? updatedFeature : feature
|
||||
);
|
||||
}
|
||||
);
|
||||
// Invalidate React Query cache to sync UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
} else if (!result.success) {
|
||||
logger.error('API features.update failed', result);
|
||||
}
|
||||
@@ -70,7 +53,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
logger.error('Failed to persist feature update:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature creation to API
|
||||
@@ -88,16 +71,12 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
const result = await api.features.create(currentProject.path, feature);
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
// Invalidate React Query cache to sync UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature creation:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, updateFeature, queryClient]
|
||||
[currentProject, updateFeature]
|
||||
);
|
||||
|
||||
// Persist feature deletion to API
|
||||
@@ -113,15 +92,11 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
}
|
||||
|
||||
await api.features.delete(currentProject.path, featureId);
|
||||
// Invalidate React Query cache to sync UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature deletion:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, queryClient]
|
||||
[currentProject]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useMemo } from 'react';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||
@@ -18,6 +10,10 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
collisionDetectionStrategy: (args: any) => any;
|
||||
onDragStart: (event: any) => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
activeFeature: Feature | null;
|
||||
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||
backgroundImageStyle: React.CSSProperties;
|
||||
@@ -68,200 +64,11 @@ interface KanbanBoardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const KANBAN_VIRTUALIZATION_THRESHOLD = 40;
|
||||
const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220;
|
||||
const KANBAN_CARD_GAP_PX = 10;
|
||||
const KANBAN_OVERSCAN_COUNT = 6;
|
||||
const VIRTUALIZATION_MEASURE_EPSILON_PX = 1;
|
||||
const REDUCED_CARD_OPACITY_PERCENT = 85;
|
||||
|
||||
type VirtualListItem = { id: string };
|
||||
|
||||
interface VirtualListState<Item extends VirtualListItem> {
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||
itemIds: string[];
|
||||
visibleItems: Item[];
|
||||
totalHeight: number;
|
||||
offsetTop: number;
|
||||
startIndex: number;
|
||||
shouldVirtualize: boolean;
|
||||
registerItem: (id: string) => (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
interface VirtualizedListProps<Item extends VirtualListItem> {
|
||||
items: Item[];
|
||||
isDragging: boolean;
|
||||
estimatedItemHeight: number;
|
||||
itemGap: number;
|
||||
overscan: number;
|
||||
virtualizationThreshold: number;
|
||||
children: (state: VirtualListState<Item>) => ReactNode;
|
||||
}
|
||||
|
||||
function findIndexForOffset(itemEnds: number[], offset: number): number {
|
||||
let low = 0;
|
||||
let high = itemEnds.length - 1;
|
||||
let result = itemEnds.length;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (itemEnds[mid] >= offset) {
|
||||
result = mid;
|
||||
high = mid - 1;
|
||||
} else {
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(result, itemEnds.length - 1);
|
||||
}
|
||||
|
||||
// Virtualize long columns while keeping full DOM during drag interactions.
|
||||
function VirtualizedList<Item extends VirtualListItem>({
|
||||
items,
|
||||
isDragging,
|
||||
estimatedItemHeight,
|
||||
itemGap,
|
||||
overscan,
|
||||
virtualizationThreshold,
|
||||
children,
|
||||
}: VirtualizedListProps<Item>) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const measurementsRef = useRef<Map<string, number>>(new Map());
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [measureVersion, setMeasureVersion] = useState(0);
|
||||
|
||||
const itemIds = useMemo(() => items.map((item) => item.id), [items]);
|
||||
const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold;
|
||||
|
||||
const itemSizes = useMemo(() => {
|
||||
return items.map((item) => {
|
||||
const measured = measurementsRef.current.get(item.id);
|
||||
const resolvedHeight = measured ?? estimatedItemHeight;
|
||||
return resolvedHeight + itemGap;
|
||||
});
|
||||
}, [items, estimatedItemHeight, itemGap, measureVersion]);
|
||||
|
||||
const itemStarts = useMemo(() => {
|
||||
let offset = 0;
|
||||
return itemSizes.map((size) => {
|
||||
const start = offset;
|
||||
offset += size;
|
||||
return start;
|
||||
});
|
||||
}, [itemSizes]);
|
||||
|
||||
const itemEnds = useMemo(() => {
|
||||
return itemStarts.map((start, index) => start + itemSizes[index]);
|
||||
}, [itemStarts, itemSizes]);
|
||||
|
||||
const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0;
|
||||
|
||||
const { startIndex, endIndex, offsetTop } = useMemo(() => {
|
||||
if (!shouldVirtualize || items.length === 0) {
|
||||
return { startIndex: 0, endIndex: items.length, offsetTop: 0 };
|
||||
}
|
||||
|
||||
const firstVisible = findIndexForOffset(itemEnds, scrollTop);
|
||||
const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight);
|
||||
const overscannedStart = Math.max(0, firstVisible - overscan);
|
||||
const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1);
|
||||
|
||||
return {
|
||||
startIndex: overscannedStart,
|
||||
endIndex: overscannedEnd,
|
||||
offsetTop: itemStarts[overscannedStart] ?? 0,
|
||||
};
|
||||
}, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]);
|
||||
|
||||
const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items;
|
||||
|
||||
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (scrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
setScrollTop(target.scrollTop);
|
||||
scrollRafRef.current = null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const registerItem = useCallback(
|
||||
(id: string) => (node: HTMLDivElement | null) => {
|
||||
if (!node || !shouldVirtualize) return;
|
||||
const measuredHeight = node.getBoundingClientRect().height;
|
||||
const previousHeight = measurementsRef.current.get(id);
|
||||
if (
|
||||
previousHeight === undefined ||
|
||||
Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX
|
||||
) {
|
||||
measurementsRef.current.set(id, measuredHeight);
|
||||
setMeasureVersion((value) => value + 1);
|
||||
}
|
||||
},
|
||||
[shouldVirtualize]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = contentRef.current;
|
||||
if (!container || typeof window === 'undefined') return;
|
||||
|
||||
const updateHeight = () => {
|
||||
setViewportHeight(container.clientHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => updateHeight());
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldVirtualize) return;
|
||||
const currentIds = new Set(items.map((item) => item.id));
|
||||
for (const id of measurementsRef.current.keys()) {
|
||||
if (!currentIds.has(id)) {
|
||||
measurementsRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
}, [items, shouldVirtualize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
contentRef,
|
||||
onScroll,
|
||||
itemIds,
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
startIndex,
|
||||
shouldVirtualize,
|
||||
registerItem,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
sensors,
|
||||
collisionDetectionStrategy,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
activeFeature,
|
||||
getColumnFeatures,
|
||||
backgroundImageStyle,
|
||||
@@ -302,7 +109,7 @@ export function KanbanBoard({
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
|
||||
// Get the keyboard shortcut for adding features
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||
|
||||
// Use responsive column widths based on window size
|
||||
@@ -318,125 +125,80 @@ export function KanbanBoard({
|
||||
)}
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
<VirtualizedList
|
||||
key={column.id}
|
||||
items={columnFeatures}
|
||||
isDragging={isDragging}
|
||||
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
|
||||
itemGap={KANBAN_CARD_GAP_PX}
|
||||
overscan={KANBAN_OVERSCAN_COUNT}
|
||||
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
|
||||
>
|
||||
{({
|
||||
contentRef,
|
||||
onScroll,
|
||||
itemIds,
|
||||
visibleItems,
|
||||
totalHeight,
|
||||
offsetTop,
|
||||
startIndex,
|
||||
shouldVirtualize,
|
||||
registerItem,
|
||||
}) => (
|
||||
<KanbanColumn
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
contentRef={contentRef}
|
||||
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||
disableItemSpacing={shouldVirtualize}
|
||||
contentClassName="perf-contain"
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
)}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<div className="h-full py-1" style={containerStyle}>
|
||||
{columns.map((column) => {
|
||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
colorClass={column.colorClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
showBorder={backgroundSettings.columnBorderEnabled}
|
||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||
headerAction={
|
||||
column.id === 'verified' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{columnFeatures.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onArchiveAllVerified}
|
||||
data-testid="archive-all-verified-button"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
<Archive className="w-3 h-3 mr-1" />
|
||||
Complete All
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onAddFeature}
|
||||
title="Add Feature"
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'backlog'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
className="h-6 w-6 p-0 relative"
|
||||
onClick={onShowCompletedModal}
|
||||
title={`Completed Features (${completedCount})`}
|
||||
data-testid="completed-features-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{completedCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'backlog' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onAddFeature}
|
||||
title="Add Feature"
|
||||
data-testid="add-feature-button"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
||||
}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
@@ -448,216 +210,178 @@ export function KanbanBoard({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : column.isPipelineStep ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Edit Pipeline Step"
|
||||
data-testid="edit-pipeline-step-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
footerAction={
|
||||
column.id === 'backlog' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full h-9 text-sm"
|
||||
onClick={onAddFeature}
|
||||
data-testid="add-feature-floating-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Pipeline Settings"
|
||||
data-testid="pipeline-settings-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : column.isPipelineStep ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenPipelineSettings}
|
||||
title="Edit Pipeline Step"
|
||||
data-testid="edit-pipeline-step-button"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
footerAction={
|
||||
column.id === 'backlog' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full h-9 text-sm"
|
||||
onClick={onAddFeature}
|
||||
data-testid="add-feature-floating-button"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{formatShortcut(addFeatureShortcut, true)}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnFeatures.map((f) => f.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(() => {
|
||||
const reduceEffects = shouldVirtualize;
|
||||
const effectiveCardOpacity = reduceEffects
|
||||
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
|
||||
: backgroundSettings.cardOpacity;
|
||||
const effectiveGlassmorphism =
|
||||
backgroundSettings.cardGlassmorphism && !reduceEffects;
|
||||
|
||||
{/* Empty state card when column has no features */}
|
||||
{columnFeatures.length === 0 && !isDragging && (
|
||||
<EmptyStateCard
|
||||
columnId={column.id}
|
||||
columnTitle={column.title}
|
||||
addFeatureShortcut={addFeatureShortcut}
|
||||
isReadOnly={isReadOnly}
|
||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
customConfig={
|
||||
column.isPipelineStep
|
||||
? {
|
||||
title: `${column.title} Empty`,
|
||||
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{columnFeatures.map((feature, index) => {
|
||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && index < 10) {
|
||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{/* Empty state card when column has no features */}
|
||||
{columnFeatures.length === 0 && !isDragging && (
|
||||
<EmptyStateCard
|
||||
columnId={column.id}
|
||||
columnTitle={column.title}
|
||||
addFeatureShortcut={addFeatureShortcut}
|
||||
isReadOnly={isReadOnly}
|
||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
customConfig={
|
||||
column.isPipelineStep
|
||||
? {
|
||||
title: `${column.title} Empty`,
|
||||
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{shouldVirtualize ? (
|
||||
<div className="relative" style={{ height: totalHeight }}>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${offsetTop}px)` }}
|
||||
>
|
||||
{visibleItems.map((feature, index) => {
|
||||
const absoluteIndex = startIndex + index;
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && absoluteIndex < 10) {
|
||||
shortcutKey =
|
||||
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={feature.id}
|
||||
ref={registerItem(feature.id)}
|
||||
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
|
||||
>
|
||||
<KanbanCard
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
columnFeatures.map((feature, index) => {
|
||||
let shortcutKey: string | undefined;
|
||||
if (column.id === 'in_progress' && index < 10) {
|
||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||
}
|
||||
return (
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={effectiveCardOpacity}
|
||||
glassmorphism={effectiveGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
reduceEffects={reduceEffects}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SortableContext>
|
||||
<KanbanCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
onEdit={() => onEdit(feature)}
|
||||
onDelete={() => onDelete(feature.id)}
|
||||
onViewOutput={() => onViewOutput(feature)}
|
||||
onVerify={() => onVerify(feature)}
|
||||
onResume={() => onResume(feature)}
|
||||
onForceStop={() => onForceStop(feature)}
|
||||
onManualVerify={() => onManualVerify(feature)}
|
||||
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</KanbanColumn>
|
||||
)}
|
||||
</VirtualizedList>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</SortableContext>
|
||||
</KanbanColumn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
<DragOverlay
|
||||
dropAnimation={{
|
||||
duration: 200,
|
||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
||||
}}
|
||||
>
|
||||
{activeFeature && (
|
||||
<div style={{ width: `${columnWidth}px` }}>
|
||||
<KanbanCard
|
||||
feature={activeFeature}
|
||||
isOverlay
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onViewOutput={() => {}}
|
||||
onVerify={() => {}}
|
||||
onResume={() => {}}
|
||||
onForceStop={() => {}}
|
||||
onManualVerify={() => {}}
|
||||
onMoveBackToInProgress={() => {}}
|
||||
onFollowUp={() => {}}
|
||||
onImplement={() => {}}
|
||||
onComplete={() => {}}
|
||||
onViewPlan={() => {}}
|
||||
onApprovePlan={() => {}}
|
||||
onSpawnTask={() => {}}
|
||||
hasContext={featuresWithContext.has(activeFeature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
|
||||
opacity={backgroundSettings.cardOpacity}
|
||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('EnhanceWithAI');
|
||||
|
||||
@@ -57,9 +56,6 @@ export function EnhanceWithAI({
|
||||
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Get current project path for per-project Claude API profile
|
||||
const currentProjectPath = useAppStore((state) => state.currentProject?.path);
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
|
||||
@@ -73,8 +69,7 @@ export function EnhanceWithAI({
|
||||
value,
|
||||
enhancementMode,
|
||||
enhancementOverride.effectiveModel,
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel,
|
||||
currentProjectPath
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
|
||||
@@ -132,12 +132,11 @@ export function DevServerLogsPanel({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-full h-full max-w-full max-h-full sm:w-[70vw] sm:max-w-[900px] sm:max-h-[85vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col gap-0 p-0 overflow-hidden"
|
||||
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="dev-server-logs-panel"
|
||||
compact
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
|
||||
@@ -25,14 +25,10 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Eye,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
Undo2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -52,7 +48,6 @@ interface WorktreeActionsDropdownProps {
|
||||
isSelected: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
@@ -61,29 +56,23 @@ interface WorktreeActionsDropdownProps {
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -92,7 +81,6 @@ export function WorktreeActionsDropdown({
|
||||
isSelected,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
@@ -100,28 +88,23 @@ export function WorktreeActionsDropdown({
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
isAutoModeRunning = false,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onMerge,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
onMerge,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Get available editors for the "Open In" submenu
|
||||
@@ -231,26 +214,6 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{/* Auto Mode toggle */}
|
||||
{onToggleAutoMode && (
|
||||
<>
|
||||
{isAutoModeRunning ? (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<span className="flex items-center mr-2">
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-500" />
|
||||
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
Stop Auto Mode
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
|
||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||
Start Auto Mode
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
||||
@@ -269,27 +232,14 @@ export function WorktreeActionsDropdown({
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!canPerformGitOps) return;
|
||||
if (!hasRemoteBranch) {
|
||||
onPushNewBranch(worktree);
|
||||
} else {
|
||||
onPush(worktree);
|
||||
}
|
||||
}}
|
||||
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
|
||||
onClick={() => canPerformGitOps && onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
{canPerformGitOps && !hasRemoteBranch && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
||||
{canPerformGitOps && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
@@ -310,6 +260,27 @@ export function WorktreeActionsDropdown({
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
{!worktree.isMain && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge to Main
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||
{effectiveDefaultEditor && (
|
||||
@@ -437,13 +408,6 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||
View Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
@@ -519,49 +483,8 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
tooltipContent="Not a git repository"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => gitRepoStatus.isGitRepo && onDiscardChanges(worktree)}
|
||||
disabled={!gitRepoStatus.isGitRepo}
|
||||
className={cn(
|
||||
'text-xs text-destructive focus:text-destructive',
|
||||
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5 mr-2" />
|
||||
Discard Changes
|
||||
{!gitRepoStatus.isGitRepo && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-green-600 focus:text-green-700',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Merge Branch
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
@@ -29,10 +28,7 @@ interface WorktreeTabProps {
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
hasRemoteBranch: boolean;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
/** Whether auto mode is running for this worktree */
|
||||
isAutoModeRunning?: boolean;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -41,12 +37,9 @@ interface WorktreeTabProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onViewChanges: (worktree: WorktreeInfo) => void;
|
||||
onDiscardChanges: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -58,7 +51,6 @@ interface WorktreeTabProps {
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
|
||||
@@ -82,9 +74,7 @@ export function WorktreeTab({
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
gitRepoStatus,
|
||||
isAutoModeRunning = false,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -93,12 +83,9 @@ export function WorktreeTab({
|
||||
onCreateBranch,
|
||||
onPull,
|
||||
onPush,
|
||||
onPushNewBranch,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onViewChanges,
|
||||
onDiscardChanges,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -110,19 +97,8 @@ export function WorktreeTab({
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
onToggleAutoMode,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
// Make the worktree tab a drop target for feature cards
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `worktree-drop-${worktree.branch}`,
|
||||
data: {
|
||||
type: 'worktree',
|
||||
branch: worktree.branch,
|
||||
path: worktree.path,
|
||||
isMain: worktree.isMain,
|
||||
},
|
||||
});
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||
@@ -209,13 +185,7 @@ export function WorktreeTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center rounded-md transition-all duration-150',
|
||||
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-105'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center rounded-md">
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -362,48 +332,23 @@ export function WorktreeTab({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isAutoModeRunning && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Auto Mode Running</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunning}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -415,7 +360,6 @@ export function WorktreeTab({
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,65 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { EditorInfo } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AvailableEditors');
|
||||
|
||||
// Re-export EditorInfo for convenience
|
||||
export type { EditorInfo };
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing available editors
|
||||
*
|
||||
* Uses React Query for data fetching with caching.
|
||||
* Provides a refresh function that clears server cache and re-detects editors.
|
||||
*/
|
||||
export function useAvailableEditors() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: editors = [], isLoading } = useAvailableEditorsQuery();
|
||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchAvailableEditors = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getAvailableEditors) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getAvailableEditors();
|
||||
if (result.success && result.result?.editors) {
|
||||
setEditors(result.result.editors);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available editors:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Mutation to refresh editors by clearing the server cache and re-detecting
|
||||
* Refresh editors by clearing the server cache and re-detecting
|
||||
* Use this when the user has installed/uninstalled editors
|
||||
*/
|
||||
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.worktree.refreshEditors();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to refresh editors');
|
||||
if (!api?.worktree?.refreshEditors) {
|
||||
// Fallback to regular fetch if refresh not available
|
||||
await fetchAvailableEditors();
|
||||
return;
|
||||
}
|
||||
return result.result?.editors ?? [];
|
||||
},
|
||||
onSuccess: (newEditors) => {
|
||||
// Update the cache with new editors
|
||||
queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
|
||||
},
|
||||
});
|
||||
const result = await api.worktree.refreshEditors();
|
||||
if (result.success && result.result?.editors) {
|
||||
setEditors(result.result.editors);
|
||||
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh editors:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [fetchAvailableEditors]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
refreshMutate();
|
||||
}, [refreshMutate]);
|
||||
useEffect(() => {
|
||||
fetchAvailableEditors();
|
||||
}, [fetchAvailableEditors]);
|
||||
|
||||
return {
|
||||
editors,
|
||||
|
||||
@@ -1,46 +1,66 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
import type { GitRepoStatus } from '../types';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { BranchInfo, GitRepoStatus } from '../types';
|
||||
|
||||
const logger = createLogger('Branches');
|
||||
|
||||
/**
|
||||
* Hook for managing branch data with React Query
|
||||
*
|
||||
* Uses useWorktreeBranches for data fetching while maintaining
|
||||
* the current interface for backward compatibility. Tracks which
|
||||
* worktree path is currently being viewed and fetches branches on demand.
|
||||
*/
|
||||
export function useBranches() {
|
||||
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [aheadCount, setAheadCount] = useState(0);
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState('');
|
||||
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
});
|
||||
|
||||
const {
|
||||
data: branchData,
|
||||
isLoading: isLoadingBranches,
|
||||
refetch,
|
||||
} = useWorktreeBranches(currentWorktreePath);
|
||||
|
||||
const branches = branchData?.branches ?? [];
|
||||
const aheadCount = branchData?.aheadCount ?? 0;
|
||||
const behindCount = branchData?.behindCount ?? 0;
|
||||
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
||||
// Use conservative defaults (false) until data is confirmed
|
||||
// This prevents the UI from assuming git capabilities before the query completes
|
||||
const gitRepoStatus: GitRepoStatus = {
|
||||
isGitRepo: branchData?.isGitRepo ?? false,
|
||||
hasCommits: branchData?.hasCommits ?? false,
|
||||
};
|
||||
/** Helper to reset branch state to initial values */
|
||||
const resetBranchState = useCallback(() => {
|
||||
setBranches([]);
|
||||
setAheadCount(0);
|
||||
setBehindCount(0);
|
||||
}, []);
|
||||
|
||||
const fetchBranches = useCallback(
|
||||
(worktreePath: string) => {
|
||||
if (worktreePath === currentWorktreePath) {
|
||||
// Same path - just refetch to get latest data
|
||||
refetch();
|
||||
} else {
|
||||
// Different path - update the tracked path (triggers new query)
|
||||
setCurrentWorktreePath(worktreePath);
|
||||
async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
logger.warn('List branches API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
||||
} else if (result.code === 'NOT_GIT_REPO') {
|
||||
// Not a git repository - clear branches silently without logging an error
|
||||
resetBranchState();
|
||||
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
|
||||
} else if (result.code === 'NO_COMMITS') {
|
||||
// Git repo but no commits yet - clear branches silently without logging an error
|
||||
resetBranchState();
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
|
||||
} else if (!result.success) {
|
||||
// Other errors - log them
|
||||
logger.warn('Failed to fetch branches:', result.error);
|
||||
resetBranchState();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch branches:', error);
|
||||
resetBranchState();
|
||||
// Reset git status to unknown state on network/API errors
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
},
|
||||
[currentWorktreePath, refetch]
|
||||
[resetBranchState]
|
||||
);
|
||||
|
||||
const resetBranchFilter = useCallback(() => {
|
||||
@@ -56,7 +76,6 @@ export function useBranches() {
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
|
||||
@@ -17,8 +17,6 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
||||
|
||||
// Match by branchName only (worktreePath is no longer stored)
|
||||
if (feature.branchName) {
|
||||
// Check if branch names match - this handles both main worktree (any primary branch name)
|
||||
// and feature worktrees
|
||||
return worktree.branch === feature.branchName;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,53 +3,128 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useSwitchBranch,
|
||||
usePullWorktree,
|
||||
usePushWorktree,
|
||||
useOpenInEditor,
|
||||
} from '@/hooks/mutations';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('WorktreeActions');
|
||||
|
||||
export function useWorktreeActions() {
|
||||
const navigate = useNavigate();
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
// Error codes that need special user-friendly handling
|
||||
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
|
||||
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
|
||||
|
||||
// Use React Query mutations
|
||||
const switchBranchMutation = useSwitchBranch();
|
||||
const pullMutation = usePullWorktree();
|
||||
const pushMutation = usePushWorktree();
|
||||
const openInEditorMutation = useOpenInEditor();
|
||||
// User-friendly messages for git status errors
|
||||
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
|
||||
NOT_GIT_REPO: 'This directory is not a git repository',
|
||||
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to handle git status errors with user-friendly messages.
|
||||
* @returns true if the error was a git status error and was handled, false otherwise.
|
||||
*/
|
||||
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
|
||||
const errorCode = result.code as GitStatusErrorCode | undefined;
|
||||
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
|
||||
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
|
||||
const navigate = useNavigate();
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (worktree: WorktreeInfo, branchName: string) => {
|
||||
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
|
||||
switchBranchMutation.mutate({
|
||||
worktreePath: worktree.path,
|
||||
branchName,
|
||||
});
|
||||
if (isSwitching || branchName === worktree.branch) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.switchBranch) {
|
||||
toast.error('Switch branch API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to switch branch');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Switch branch failed:', error);
|
||||
toast.error('Failed to switch branch');
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[switchBranchMutation]
|
||||
[isSwitching, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePull = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (pullMutation.isPending) return;
|
||||
pullMutation.mutate(worktree.path);
|
||||
if (isPulling) return;
|
||||
setIsPulling(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.pull) {
|
||||
toast.error('Pull API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.pull(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to pull latest changes');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Pull failed:', error);
|
||||
toast.error('Failed to pull latest changes');
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
},
|
||||
[pullMutation]
|
||||
[isPulling, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handlePush = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (pushMutation.isPending) return;
|
||||
pushMutation.mutate({
|
||||
worktreePath: worktree.path,
|
||||
});
|
||||
if (isPushing) return;
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error('Push API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to push changes');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Push failed:', error);
|
||||
toast.error('Failed to push changes');
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
},
|
||||
[pushMutation]
|
||||
[isPushing, fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
const handleOpenInIntegratedTerminal = useCallback(
|
||||
@@ -65,15 +140,23 @@ export function useWorktreeActions() {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
openInEditorMutation.mutate({
|
||||
worktreePath: worktree.path,
|
||||
editorCommand,
|
||||
});
|
||||
},
|
||||
[openInEditorMutation]
|
||||
);
|
||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInEditor) {
|
||||
logger.warn('Open in editor API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Open in editor failed:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenInExternalTerminal = useCallback(
|
||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||
@@ -97,9 +180,9 @@ export function useWorktreeActions() {
|
||||
);
|
||||
|
||||
return {
|
||||
isPulling: pullMutation.isPending,
|
||||
isPushing: pushMutation.isPending,
|
||||
isSwitching: switchBranchMutation.isPending,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
setIsActivating,
|
||||
handleSwitchBranch,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('Worktrees');
|
||||
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
refreshTrigger?: number;
|
||||
@@ -17,46 +18,62 @@ export function useWorktrees({
|
||||
refreshTrigger = 0,
|
||||
onRemovedWorktrees,
|
||||
}: UseWorktreesOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
// Use the React Query hook
|
||||
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
||||
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
||||
|
||||
// Sync worktrees to Zustand store when they change
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
setWorktreesInStore(projectPath, worktrees);
|
||||
}
|
||||
}, [worktrees, projectPath, setWorktreesInStore]);
|
||||
|
||||
// Handle removed worktrees callback when data changes
|
||||
const prevRemovedWorktreesRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (data?.removedWorktrees && data.removedWorktrees.length > 0) {
|
||||
// Create a stable key to avoid duplicate callbacks
|
||||
const key = JSON.stringify(data.removedWorktrees);
|
||||
if (key !== prevRemovedWorktreesRef.current) {
|
||||
prevRemovedWorktreesRef.current = key;
|
||||
onRemovedWorktrees?.(data.removedWorktrees);
|
||||
const fetchWorktrees = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
if (!projectPath) return;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}
|
||||
}, [data?.removedWorktrees, onRemovedWorktrees]);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
logger.warn('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
// Pass forceRefreshGitHub when this is a manual refresh (not silent polling)
|
||||
// This clears the GitHub remote cache so users can re-detect after adding a remote
|
||||
const forceRefreshGitHub = !silent;
|
||||
const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
// Return removed worktrees so they can be handled by the caller
|
||||
return result.removedWorktrees;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch worktrees:', error);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[projectPath, setWorktreesInStore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Handle refresh trigger
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
// Invalidate and refetch to get fresh data including any removed worktrees
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
fetchWorktrees().then((removedWorktrees) => {
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [refreshTrigger, projectPath, queryClient]);
|
||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||
|
||||
// Use a ref to track the current worktree to avoid running validation
|
||||
// when selection changes (which could cause a race condition with stale worktrees list)
|
||||
@@ -94,14 +111,6 @@ export function useWorktrees({
|
||||
[projectPath, setCurrentWorktree]
|
||||
);
|
||||
|
||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
});
|
||||
return refetch();
|
||||
}, [projectPath, queryClient, refetch]);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
|
||||
@@ -61,12 +61,6 @@ export interface PRInfo {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MergeConflictInfo {
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
targetWorktreePath: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -76,9 +70,7 @@ export interface WorktreePanelProps {
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||
onMerge: (worktree: WorktreeInfo) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
@@ -22,11 +21,6 @@ import {
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
} from './components';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -37,8 +31,7 @@ export function WorktreePanel({
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchDeletedDuringMerge,
|
||||
onMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
@@ -57,6 +50,7 @@ export function WorktreePanel({
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
@@ -69,7 +63,6 @@ export function WorktreePanel({
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
@@ -89,97 +82,41 @@ export function WorktreePanel({
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions();
|
||||
} = useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
});
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
features,
|
||||
});
|
||||
|
||||
// Auto-mode state management using the store
|
||||
// Use separate selectors to avoid creating new object references on each render
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
|
||||
const getAutoModeWorktreeKey = useCallback(
|
||||
(projectId: string, branchName: string | null): string => {
|
||||
return `${projectId}::${branchName ?? '__main__'}`;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Helper to check if auto-mode is running for a specific worktree
|
||||
const isAutoModeRunningForWorktree = useCallback(
|
||||
(worktree: WorktreeInfo): boolean => {
|
||||
if (!currentProject) return false;
|
||||
const branchName = worktree.isMain ? null : worktree.branch;
|
||||
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
|
||||
return autoModeByWorktree[key]?.isRunning ?? false;
|
||||
},
|
||||
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
|
||||
);
|
||||
|
||||
// Handler to toggle auto-mode for a worktree
|
||||
const handleToggleAutoMode = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
// Import the useAutoMode to get start/stop functions
|
||||
// Since useAutoMode is a hook, we'll use the API client directly
|
||||
const api = getHttpApiClient();
|
||||
const branchName = worktree.isMain ? null : worktree.branch;
|
||||
const isRunning = isAutoModeRunningForWorktree(worktree);
|
||||
|
||||
try {
|
||||
if (isRunning) {
|
||||
const result = await api.autoMode.stop(projectPath, branchName);
|
||||
if (result.success) {
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode stopped for ${desc}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to stop Auto Mode');
|
||||
}
|
||||
} else {
|
||||
const result = await api.autoMode.start(projectPath, branchName);
|
||||
if (result.success) {
|
||||
const desc = branchName ? `worktree ${branchName}` : 'main branch';
|
||||
toast.success(`Auto Mode started for ${desc}`);
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start Auto Mode');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error toggling Auto Mode');
|
||||
console.error('Auto mode toggle error:', error);
|
||||
}
|
||||
},
|
||||
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
||||
);
|
||||
|
||||
// Check if init script exists for the project using React Query
|
||||
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||
const hasInitScript = initScriptData?.exists ?? false;
|
||||
|
||||
// View changes dialog state
|
||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Discard changes confirmation dialog state
|
||||
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
|
||||
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
|
||||
// Track whether init script exists for the project
|
||||
const [hasInitScript, setHasInitScript] = useState(false);
|
||||
|
||||
// Log panel state management
|
||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
// Push to remote dialog state
|
||||
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
||||
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||
useEffect(() => {
|
||||
if (!projectPath) {
|
||||
setHasInitScript(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge branch dialog state
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||
const checkInitScript = async () => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.getInitScript(projectPath);
|
||||
setHasInitScript(result.success && result.exists);
|
||||
} catch {
|
||||
setHasInitScript(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkInitScript();
|
||||
}, [projectPath]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -244,41 +181,6 @@ export function WorktreePanel({
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
const handleViewChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setViewChangesWorktree(worktree);
|
||||
setViewChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
|
||||
setDiscardChangesWorktree(worktree);
|
||||
setDiscardChangesDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDiscardChanges = useCallback(async () => {
|
||||
if (!discardChangesWorktree) return;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Changes discarded', {
|
||||
description: `Discarded changes in ${discardChangesWorktree.branch}`,
|
||||
});
|
||||
// Refresh worktrees to update the changes status
|
||||
fetchWorktrees({ silent: true });
|
||||
} else {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to discard changes', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [discardChangesWorktree, fetchWorktrees]);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
@@ -291,54 +193,6 @@ export function WorktreePanel({
|
||||
// Keep logPanelWorktree set for smooth close animation
|
||||
}, []);
|
||||
|
||||
// Handle opening the push to remote dialog
|
||||
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||
setPushToRemoteWorktree(worktree);
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle confirming the push to remote dialog
|
||||
const handleConfirmPushToRemote = useCallback(
|
||||
async (worktree: WorktreeInfo, remote: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.push) {
|
||||
toast.error('Push API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.push(worktree.path, false, remote);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to push changes');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to push changes');
|
||||
}
|
||||
},
|
||||
[fetchBranches, fetchWorktrees]
|
||||
);
|
||||
|
||||
// Handle opening the merge dialog
|
||||
const handleMerge = useCallback((worktree: WorktreeInfo) => {
|
||||
setMergeWorktree(worktree);
|
||||
setMergeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
|
||||
const handleMerged = useCallback(
|
||||
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||
fetchWorktrees();
|
||||
// If the branch was deleted, notify parent to reassign features to main
|
||||
if (deletedBranch && onBranchDeletedDuringMerge) {
|
||||
onBranchDeletedDuringMerge(mergedWorktree.branch);
|
||||
}
|
||||
},
|
||||
[fetchWorktrees, onBranchDeletedDuringMerge]
|
||||
);
|
||||
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
@@ -384,35 +238,29 @@ export function WorktreePanel({
|
||||
standalone={true}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -446,54 +294,6 @@ export function WorktreePanel({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Changes Dialog */}
|
||||
<ViewWorktreeChangesDialog
|
||||
open={viewChangesDialogOpen}
|
||||
onOpenChange={setViewChangesDialogOpen}
|
||||
worktree={viewChangesWorktree}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
onClose={handleCloseLogPanel}
|
||||
worktree={logPanelWorktree}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
|
||||
{/* Push to Remote Dialog */}
|
||||
<PushToRemoteDialog
|
||||
open={pushToRemoteDialogOpen}
|
||||
onOpenChange={setPushToRemoteDialogOpen}
|
||||
worktree={pushToRemoteWorktree}
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -527,9 +327,7 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -538,24 +336,20 @@ export function WorktreePanel({
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
@@ -593,9 +387,7 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
@@ -604,24 +396,20 @@ export function WorktreePanel({
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
);
|
||||
@@ -656,27 +444,6 @@ export function WorktreePanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Changes Dialog */}
|
||||
<ViewWorktreeChangesDialog
|
||||
open={viewChangesDialogOpen}
|
||||
onOpenChange={setViewChangesDialogOpen}
|
||||
worktree={viewChangesWorktree}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Discard Changes Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={discardChangesDialogOpen}
|
||||
onOpenChange={setDiscardChangesDialogOpen}
|
||||
onConfirm={handleConfirmDiscardChanges}
|
||||
title="Discard Changes"
|
||||
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
|
||||
icon={Undo2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Discard Changes"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
@@ -685,24 +452,6 @@ export function WorktreePanel({
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
|
||||
{/* Push to Remote Dialog */}
|
||||
<PushToRemoteDialog
|
||||
open={pushToRemoteDialogOpen}
|
||||
onOpenChange={setPushToRemoteDialogOpen}
|
||||
worktree={pushToRemoteWorktree}
|
||||
onConfirm={handleConfirmPushToRemote}
|
||||
/>
|
||||
|
||||
{/* Merge Branch Dialog */}
|
||||
<MergeWorktreeDialog
|
||||
open={mergeDialogOpen}
|
||||
onOpenChange={setMergeDialogOpen}
|
||||
projectPath={projectPath}
|
||||
worktree={mergeWorktree}
|
||||
onMerged={handleMerged}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user