mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat(core): implement git worktree checkpoint system
Add comprehensive worktree management system to enable task isolation and rollback capabilities. This allows users to revert agent changes if they don't satisfy requirements or break functionality. Key components: - New WorktreeManager service for branch and worktree operations - GitDiffPanel component for visualizing changes - Enhanced UI components with worktree integration - Auto-mode service enhancements for worktree workflow Modified files: worktree-manager.js, git-diff-panel.tsx, main.js, preload.js, feature-loader.js, agent-output-modal.tsx, board-view.tsx, kanban-card.tsx, electron.ts, app-store.ts, electron.d.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
@@ -1,259 +0,0 @@
|
||||
📋 Planning implementation for: So we added support for codex cli models but when we select them on model tab its still trying to use claude model there is some race condition in the code
|
||||
|
||||
1] [AutoMode] Running specific feature: feature-1765334118538-zc6n2ngu8
|
||||
[1] [AutoMode] Running feature: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureLoader] Created backup at /Users/shirone/Documents/Projekty/automaker/.automaker/feature_list.backup.json
|
||||
[1] [FeatureLoader] Updated feature feature-1765334118538-zc6n2ngu8: status=in_progress
|
||||
[1] [FeatureLoader] Successfully saved 13 features to feature_list.json
|
||||
[1] [FeatureExecutor] Implementing: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] Phase: PLANNING for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] getModelString: modelKey=gpt-5.1-codex, modelString=claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Using provider: Codex/OpenAI, model: claude-opus-4-5-20251101, thinking: none
|
||||
[1] [FeatureExecutor] Phase: ACTION for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Feature model: gpt-5.1-codex, modelString: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Error implementing feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [FeatureExecutor] Error details: {
|
||||
[1] message: "Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.",
|
||||
[1] stack: "Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n" +
|
||||
[1] ' at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n' +
|
||||
[1] ' at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n' +
|
||||
[1] ' at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n' +
|
||||
[1] ' at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)',
|
||||
[1] name: 'Error',
|
||||
[1] code: undefined,
|
||||
[1] model: 'not initialized',
|
||||
[1] provider: 'unknown',
|
||||
[1] isCodex: 'unknown'
|
||||
[1] }
|
||||
[1] [AutoMode] Error running feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [IPC] auto-mode:run-feature error: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
|
||||
⚡ Executing implementation for: So we added support for codex cli models but when we select them on model tab its still trying to use claude model there is some race condition in the code
|
||||
|
||||
1] [AutoMode] Running specific feature: feature-1765334118538-zc6n2ngu8
|
||||
[1] [AutoMode] Running feature: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureLoader] Created backup at /Users/shirone/Documents/Projekty/automaker/.automaker/feature_list.backup.json
|
||||
[1] [FeatureLoader] Updated feature feature-1765334118538-zc6n2ngu8: status=in_progress
|
||||
[1] [FeatureLoader] Successfully saved 13 features to feature_list.json
|
||||
[1] [FeatureExecutor] Implementing: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] Phase: PLANNING for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] getModelString: modelKey=gpt-5.1-codex, modelString=claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Using provider: Codex/OpenAI, model: claude-opus-4-5-20251101, thinking: none
|
||||
[1] [FeatureExecutor] Phase: ACTION for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Feature model: gpt-5.1-codex, modelString: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Error implementing feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [FeatureExecutor] Error details: {
|
||||
[1] message: "Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.",
|
||||
[1] stack: "Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n" +
|
||||
[1] ' at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n' +
|
||||
[1] ' at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n' +
|
||||
[1] ' at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n' +
|
||||
[1] ' at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)',
|
||||
[1] name: 'Error',
|
||||
[1] code: undefined,
|
||||
[1] model: 'not initialized',
|
||||
[1] provider: 'unknown',
|
||||
[1] isCodex: 'unknown'
|
||||
[1] }
|
||||
[1] [AutoMode] Error running feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [IPC] auto-mode:run-feature error: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
|
||||
Starting code implementation...
|
||||
|
||||
🔧 Tool: Read
|
||||
|
||||
🔧 Tool: Grep
|
||||
Let me read the model-registry.js file to understand how `getModelString` works there:
|
||||
🔧 Tool: Read
|
||||
|
||||
🔧 Tool: Read
|
||||
Now I understand the issue! The problem is in `feature-executor.js` in the `getModelString` method. Let me trace through the logic:
|
||||
|
||||
1. When `modelKey = "gpt-5.1-codex"`, it calls `ModelRegistry.getModelString(modelKey)`
|
||||
2. In `model-registry.js`, `getModelString` looks up `MODELS[modelId]` and returns `model.modelString`
|
||||
3. However, looking at the flow more carefully, I see that `ModelRegistry.getModelString('gpt-5.1-codex')` should return `'gpt-5.1-codex'` correctly since `MODELS['gpt-5.1-codex'].modelString = 'gpt-5.1-codex'`
|
||||
|
||||
But the log shows:
|
||||
```
|
||||
[FeatureExecutor] getModelString: modelKey=gpt-5.1-codex, modelString=claude-opus-4-5-20251101
|
||||
```
|
||||
|
||||
Let me look at the `getModelString` in feature-executor.js more closely. The issue is the **fallback logic**:
|
||||
|
||||
```javascript
|
||||
// Use the registry for model lookup
|
||||
let modelString = ModelRegistry.getModelString(modelKey);
|
||||
|
||||
// Fallback to MODEL_MAP if registry doesn't have it
|
||||
if (!modelString || modelString === modelKey) {
|
||||
modelString = MODEL_MAP[modelKey];
|
||||
}
|
||||
|
||||
// Final fallback to opus
|
||||
if (!modelString) {
|
||||
modelString = MODEL_MAP.opus;
|
||||
}
|
||||
```
|
||||
|
||||
The problem is in line 42-44:
|
||||
- `ModelRegistry.getModelString('gpt-5.1-codex')` returns `'gpt-5.1-codex'` (which is correct!)
|
||||
- But then the condition `modelString === modelKey` is TRUE (because both are `'gpt-5.1-codex'`)
|
||||
- So it falls through to `MODEL_MAP[modelKey]` which is `MODEL_MAP['gpt-5.1-codex']`
|
||||
- Since `MODEL_MAP` only has `haiku`, `sonnet`, and `opus`, it returns `undefined`
|
||||
- Then the final fallback `if (!modelString)` kicks in and returns `MODEL_MAP.opus` which is `'claude-opus-4-5-20251101'`
|
||||
|
||||
This is the race condition / bug! The code assumes that if the modelString equals the modelKey, it means the registry didn't have a proper mapping. But for Codex models, the modelString IS the same as the modelKey (they're designed that way).
|
||||
|
||||
Let me fix this:
|
||||
🔧 Tool: Edit
|
||||
The fix is in place. Now let me verify that the `resumeFeatureWithContext` method in the same file doesn't have a similar issue. Let me check if it uses the same `getModelString` method:
|
||||
🔧 Tool: Read
|
||||
I see that `resumeFeatureWithContext` uses `this.getModelString(feature)` at line 562, which is the same method I just fixed. However, I notice that it's still using the Claude SDK `query` directly (line 616) instead of checking if it should use the Codex provider. Let me check the `resumeFeatureWithContext` method more thoroughly:
|
||||
🔧 Tool: Grep
|
||||
Let me see more of the resumeFeatureWithContext method:
|
||||
🔧 Tool: Read
|
||||
I see that `resumeFeatureWithContext` also doesn't check for Codex models - it always uses the Claude SDK `query`. This is the same issue as in `implementFeature`. Let me fix the `resumeFeatureWithContext` method to also support Codex models properly. First let me see the full method context to understand how to apply the fix:
|
||||
🔧 Tool: Read
|
||||
Now I need to fix the `resumeFeatureWithContext` method to support Codex models. The fix should be similar to `implementFeature`:
|
||||
🔧 Tool: Edit
|
||||
Input: {
|
||||
"file_path": "/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js",
|
||||
"old_string": " console.log(`[FeatureExecutor] Resuming with model: ${modelString}, thinking: ${feature.thinkingLevel || 'none'}`);\n\n const options = {\n model: modelString,\n systemPrompt: promptBuilder.getVerificationPrompt(),\n maxTurns: 1000,\n cwd: projectPath,\n mcpServers: {\n \"automaker-tools\": featureToolsServer\n },\n allowedTools: [\"Read\", \"Write\", \"Edit\", \"Glob\", \"Grep\", \"Bash\", \"WebSearch\", \"WebFetch\", \"mcp__automaker-tools__UpdateFeatureStatus\"],\n permissionMode: \"acceptEdits\",\n sandbox: {\n enabled: true,\n autoAllowBashIfSandboxed: true,\n },\n abortController: abortController,\n };\n\n // Add thinking configuration if enabled\n if (thinkingConfig) {\n options.thinking = thinkingConfig;\n }\n\n // Build prompt with previous context\n const prompt = promptBuilder.buildResumePrompt(feature, previousContext);\n\n const currentQuery = query({ prompt, options });\n execution.query = currentQuery;",
|
||||
"new_string": " const isCodex = this.isCodexModel(feature);\n const providerName = isCodex ? 'Codex/OpenAI' : 'Claude';\n console.log(`[FeatureExecutor] Resuming with provider: ${providerName}, model: ${modelString}, thinking: ${feature.thinkingLevel || 'none'}`);\n\n const options = {\n model: modelString,\n systemPrompt: promptBuilder.getVerificationPrompt(),\n maxTurns: 1000,\n cwd: projectPath,\n mcpServers: {\n \"automaker-tools\": featureToolsServer\n },\n allowedTools: [\"Read\", \"Write\", \"Edit\", \"Glob\", \"Grep\", \"Bash\", \"WebSearch\", \"WebFetch\", \"mcp__automaker-tools__UpdateFeatureStatus\"],\n permissionMode: \"acceptEdits\",\n sandbox: {\n enabled: true,\n autoAllowBashIfSandboxed: true,\n },\n abortController: abortController,\n };\n\n // Add thinking configuration if enabled\n if (thinkingConfig) {\n options.thinking = thinkingConfig;\n }\n\n // Build prompt with previous context\n const prompt = promptBuilder.buildResumePrompt(feature, previousContext);\n\n // Use appropriate provider based on model type\n let currentQuery;\n if (isCodex) {\n // Validate that model string is actually a Codex model\n if (modelString.startsWith('claude-')) {\n console.error(`[FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: ${modelString}`);\n throw new Error(`Invalid model configuration: Codex provider cannot use Claude model '${modelString}'. Please check feature model setting.`);\n }\n\n console.log(`[FeatureExecutor] Using Codex provider for resume with model: ${modelString}`);\n const provider = this.getProvider(feature);\n currentQuery = provider.executeQuery({\n prompt,\n model: modelString,\n cwd: projectPath,\n systemPrompt: promptBuilder.getVerificationPrompt(),\n maxTurns: 20,\n allowedTools: options.allowedTools,\n abortController: abortController,\n env: {\n OPENAI_API_KEY: process.env.OPENAI_API_KEY\n }\n });\n } else {\n // Use Claude SDK\n currentQuery = query({ prompt, options });\n }\n execution.query = currentQuery;"
|
||||
}Now let me verify the complete file is syntactically correct by reading key sections:
|
||||
🔧 Tool: Bash
|
||||
Input: {
|
||||
"command": "node -c app/electron/services/feature-executor.js 2>&1",
|
||||
"description": "Check JS syntax of feature-executor.js"
|
||||
}The syntax check passed (no output means no errors). Let me also verify the model-registry.js is correct:
|
||||
🔧 Tool: Bash
|
||||
Input: {
|
||||
"command": "node -c app/electron/services/model-registry.js 2>&1",
|
||||
"description": "Check JS syntax of model-registry.js"
|
||||
}Now let me verify the fix by reading the relevant parts of the file to ensure the logic is correct:
|
||||
🔧 Tool: Read
|
||||
Input: {
|
||||
"file_path": "/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js",
|
||||
"offset": 31,
|
||||
"limit": 50
|
||||
}The fix looks correct. Let me create a simple test script to verify the logic works correctly:
|
||||
🔧 Tool: Bash
|
||||
Input: {
|
||||
"command": "node -e \"\nconst { ModelRegistry } = require('./app/electron/services/model-registry.js');\n\n// Test Codex model detection\nconsole.log('Testing Codex model detection:');\nconsole.log('gpt-5.1-codex is Codex:', ModelRegistry.isCodexModel('gpt-5.1-codex'));\nconsole.log('gpt-5.1-codex-max is Codex:', ModelRegistry.isCodexModel('gpt-5.1-codex-max'));\nconsole.log('opus is Codex:', ModelRegistry.isCodexModel('opus'));\nconsole.log('haiku is Codex:', ModelRegistry.isCodexModel('haiku'));\n\nconsole.log('');\nconsole.log('Testing model string lookup:');\nconsole.log('gpt-5.1-codex string:', ModelRegistry.getModelString('gpt-5.1-codex'));\nconsole.log('gpt-5.1-codex-max string:', ModelRegistry.getModelString('gpt-5.1-codex-max'));\nconsole.log('opus string:', ModelRegistry.getModelString('opus'));\nconsole.log('haiku string:', ModelRegistry.getModelString('haiku'));\n\nconsole.log('');\nconsole.log('Testing getModel:');\nconst codexModel = ModelRegistry.getModel('gpt-5.1-codex');\nconsole.log('gpt-5.1-codex model:', codexModel ? codexModel.modelString : 'NOT FOUND');\n\"",
|
||||
"description": "Test model registry functions"
|
||||
}Excellent! The model registry is working correctly. Now let me test the feature-executor's `getModelString` method to make sure the fix works:
|
||||
🔧 Tool: Bash
|
||||
Input: {
|
||||
"command": "node -e \"\nconst featureExecutor = require('./app/electron/services/feature-executor.js');\n\nconsole.log('Testing getModelString with Codex features:');\nconsole.log('');\n\n// Test with Codex model\nconst codexFeature = { model: 'gpt-5.1-codex', description: 'Test' };\nconst codexResult = featureExecutor.getModelString(codexFeature);\nconsole.log('gpt-5.1-codex result:', codexResult);\nconsole.log('Expected: gpt-5.1-codex');\nconsole.log('Correct:', codexResult === 'gpt-5.1-codex');\n\nconsole.log('');\n\n// Test with Codex Max model\nconst codexMaxFeature = { model: 'gpt-5.1-codex-max', description: 'Test' };\nconst codexMaxResult = featureExecutor.getModelString(codexMaxFeature);\nconsole.log('gpt-5.1-codex-max result:', codexMaxResult);\nconsole.log('Expected: gpt-5.1-codex-max');\nconsole.log('Correct:', codexMaxResult === 'gpt-5.1-codex-max');\n\nconsole.log('');\n\n// Test with Claude model (should still work)\nconst claudeFeature = { model: 'opus', description: 'Test' };\nconst claudeResult = featureExecutor.getModelString(claudeFeature);\nconsole.log('opus result:', claudeResult);\nconsole.log('Expected: claude-opus-4-5-20251101');\nconsole.log('Correct:', claudeResult === 'claude-opus-4-5-20251101');\n\nconsole.log('');\n\n// Test with Haiku model\nconst haikuFeature = { model: 'haiku', description: 'Test' };\nconst haikuResult = featureExecutor.getModelString(haikuFeature);\nconsole.log('haiku result:', haikuResult);\nconsole.log('Expected: claude-haiku-4-5');\nconsole.log('Correct:', haikuResult === 'claude-haiku-4-5');\n\nconsole.log('');\nconsole.log('Summary:');\nconsole.log('All tests passed:', \n codexResult === 'gpt-5.1-codex' && \n codexMaxResult === 'gpt-5.1-codex-max' && \n claudeResult === 'claude-opus-4-5-20251101' &&\n haikuResult === 'claude-haiku-4-5'\n);\n\"",
|
||||
"description": "Test feature executor getModelString"
|
||||
}All tests pass. The fix is working correctly. Let me now update the feature status:
|
||||
🔧 Tool: mcp__automaker-tools__UpdateFeatureStatus
|
||||
Input: {
|
||||
"featureId": "feature-1765334243840-qmnc0ez5o",
|
||||
"status": "verified",
|
||||
"summary": "Fixed race condition in Codex model selection. Modified: feature-executor.js. The getModelString() method was incorrectly falling back to Claude opus model when Codex models were selected because it checked if modelString === modelKey (which is true for Codex). Fixed by checking isCodexModel() first and using the registry directly for Codex models. Also updated resumeFeatureWithContext() to properly use Codex provider when needed."
|
||||
}The feature has been implemented and marked for manual review. Here's a summary of what was fixed:
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
**File Modified:** `app/electron/services/feature-executor.js`
|
||||
|
||||
### Problem
|
||||
When a Codex model (e.g., `gpt-5.1-codex`) was selected in the model tab, the `getModelString()` method was incorrectly returning the Claude opus model string (`claude-opus-4-5-20251101`) instead of the Codex model string.
|
||||
|
||||
### Root Cause
|
||||
The old `getModelString()` logic had this flow:
|
||||
1. Get `modelString` from `ModelRegistry.getModelString(modelKey)`
|
||||
2. If `modelString === modelKey`, fall back to `MODEL_MAP[modelKey]`
|
||||
3. If that's also empty, fall back to `MODEL_MAP.opus`
|
||||
|
||||
For Codex models, the registry returns the same string as the key (e.g., `'gpt-5.1-codex'` → `'gpt-5.1-codex'`), which triggered condition #2. Since `MODEL_MAP` only has Claude models, it returned `undefined`, triggering condition #3 which returned the Claude opus model.
|
||||
|
||||
### Fix
|
||||
1. **`getModelString()`**: Now checks `ModelRegistry.isCodexModel(modelKey)` first. If it's a Codex model, it directly looks up the model in the registry and returns its `modelString`. This avoids the problematic fallback logic.
|
||||
|
||||
2. **`resumeFeatureWithContext()`**: Also updated to properly use the Codex provider when resuming a feature with a Codex model, similar to how `implementFeature()` already does.
|
||||
|
||||
### Verification
|
||||
Tested the fix with a Node.js script that confirmed:
|
||||
- `gpt-5.1-codex` → returns `'gpt-5.1-codex'` ✓
|
||||
- `gpt-5.1-codex-max` → returns `'gpt-5.1-codex-max'` ✓
|
||||
- `opus` → returns `'claude-opus-4-5-20251101'` ✓
|
||||
- `haiku` → returns `'claude-haiku-4-5'` ✓
|
||||
✅ Verifying implementation for: So we added support for codex cli models but when we select them on model tab its still trying to use claude model there is some race condition in the code
|
||||
|
||||
1] [AutoMode] Running specific feature: feature-1765334118538-zc6n2ngu8
|
||||
[1] [AutoMode] Running feature: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureLoader] Created backup at /Users/shirone/Documents/Projekty/automaker/.automaker/feature_list.backup.json
|
||||
[1] [FeatureLoader] Updated feature feature-1765334118538-zc6n2ngu8: status=in_progress
|
||||
[1] [FeatureLoader] Successfully saved 13 features to feature_list.json
|
||||
[1] [FeatureExecutor] Implementing: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] Phase: PLANNING for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] getModelString: modelKey=gpt-5.1-codex, modelString=claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Using provider: Codex/OpenAI, model: claude-opus-4-5-20251101, thinking: none
|
||||
[1] [FeatureExecutor] Phase: ACTION for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
|
||||
[1] [FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Feature model: gpt-5.1-codex, modelString: claude-opus-4-5-20251101
|
||||
[1] [FeatureExecutor] Error implementing feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [FeatureExecutor] Error details: {
|
||||
[1] message: "Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.",
|
||||
[1] stack: "Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n" +
|
||||
[1] ' at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n' +
|
||||
[1] ' at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n' +
|
||||
[1] ' at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n' +
|
||||
[1] ' at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)',
|
||||
[1] name: 'Error',
|
||||
[1] code: undefined,
|
||||
[1] model: 'not initialized',
|
||||
[1] provider: 'unknown',
|
||||
[1] isCodex: 'unknown'
|
||||
[1] }
|
||||
[1] [AutoMode] Error running feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
[1] [IPC] auto-mode:run-feature error: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.
|
||||
[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)
|
||||
[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)
|
||||
[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12
|
||||
[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)
|
||||
|
||||
Verifying implementation and checking test results...
|
||||
✓ Verification successful: All tests passed
|
||||
|
||||
✅ Task completed: Let me read the model-registry.js file to understand how `getModelString` works there:Now I understand the issue! The problem is in `feature-executor.js` in the `getModelString` method. Let me trace through the logic:
|
||||
|
||||
1. When `modelKey = "gpt-5.1-codex"`, it calls `ModelRegistry.getModelString(modelKey)`
|
||||
2. In `model-registry.js`, `getModelString` looks up `MODELS[modelId]` and returns `model.modelString`
|
||||
3. However, looking at the flow more carefully, I see that `ModelRegistry.getModelString('
|
||||
@@ -142,19 +142,6 @@
|
||||
"model": "haiku",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765334243840-qmnc0ez5o",
|
||||
"category": "Core",
|
||||
"description": "So we added support for codex cli models but when we select them on model tab its still trying to use claude model there is some race condition in the code\n\n1] [AutoMode] Running specific feature: feature-1765334118538-zc6n2ngu8\n[1] [AutoMode] Running feature: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task\n[1] [FeatureLoader] Created backup at /Users/shirone/Documents/Projekty/automaker/.automaker/feature_list.backup.json\n[1] [FeatureLoader] Updated feature feature-1765334118538-zc6n2ngu8: status=in_progress\n[1] [FeatureLoader] Successfully saved 13 features to feature_list.json\n[1] [FeatureExecutor] Implementing: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task\n[1] [FeatureExecutor] Phase: PLANNING for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task\n[1] [FeatureExecutor] getModelString: modelKey=gpt-5.1-codex, modelString=claude-opus-4-5-20251101\n[1] [FeatureExecutor] Using provider: Codex/OpenAI, model: claude-opus-4-5-20251101, thinking: none\n[1] [FeatureExecutor] Phase: ACTION for For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task\n[1] [FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: claude-opus-4-5-20251101\n[1] [FeatureExecutor] Feature model: gpt-5.1-codex, modelString: claude-opus-4-5-20251101\n[1] [FeatureExecutor] Error implementing feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)\n[1] [FeatureExecutor] Error details: {\n[1] message: \"Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\",\n[1] stack: \"Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\\n\" +\n[1] ' at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\\n' +\n[1] ' at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\\n' +\n[1] ' at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\\n' +\n[1] ' at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)',\n[1] name: 'Error',\n[1] code: undefined,\n[1] model: 'not initialized',\n[1] provider: 'unknown',\n[1] isCodex: 'unknown'\n[1] }\n[1] [AutoMode] Error running feature: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)\n[1] [IPC] auto-mode:run-feature error: Error: Invalid model configuration: Codex provider cannot use Claude model 'claude-opus-4-5-20251101'. Please check feature model setting.\n[1] at FeatureExecutor.implementFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/services/feature-executor.js:303:17)\n[1] at async AutoModeService.runFeature (/Users/shirone/Documents/Projekty/automaker/app/electron/auto-mode-service.js:142:22)\n[1] at async /Users/shirone/Documents/Projekty/automaker/app/electron/main.js:421:12\n[1] at async Session.<anonymous> (node:electron/js2c/browser_init:2:107280)\n",
|
||||
"steps": [],
|
||||
"status": "waiting_approval",
|
||||
"startedAt": "2025-12-10T02:37:26.038Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed race condition in Codex model selection. Modified: feature-executor.js. The getModelString() method was incorrectly falling back to Claude opus model when Codex models were selected because it checked if modelString === modelKey (which is true for Codex). Fixed by checking isCodexModel() first and using the registry directly for Codex models. Also updated resumeFeatureWithContext() to properly use Codex provider when needed.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "ultrathink"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765334441225-h3j832sbz",
|
||||
"category": "Kanban",
|
||||
@@ -206,36 +193,74 @@
|
||||
"model": "sonnet",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765360665626-u2vhr80aa",
|
||||
"category": "Uncategorized",
|
||||
"description": "I dont want you to change any code describe what u see in attached image",
|
||||
"steps": [],
|
||||
"status": "backlog",
|
||||
"startedAt": "2025-12-10T09:57:47.494Z",
|
||||
"imagePaths": [
|
||||
{
|
||||
"id": "img-1765360662146-d5qi79j88",
|
||||
"path": "/Users/shirone/Library/Application Support/automaker/images/1765360662144-2pt9bt1u4_image-test.png",
|
||||
"filename": "image-test.png",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
],
|
||||
"skipTests": true,
|
||||
"model": "gpt-5.1-codex",
|
||||
"thinkingLevel": "none"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765360739103-3h218d1nn",
|
||||
"category": "Kanban",
|
||||
"description": "When u write new feature for ai agent and attacht context images and change tab to choose diff model and go back to prompt tab the image preview break and im not sure if it even saved properly in state to be later attached check it out for me",
|
||||
"steps": [],
|
||||
"status": "waiting_approval",
|
||||
"status": "verified",
|
||||
"startedAt": "2025-12-10T09:59:02.988Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Fixed image preview breaking when switching tabs in Add Feature modal. Added previewMap/onPreviewMapChange props to DescriptionImageDropZone component to lift preview state up to parent. Modified: description-image-dropzone.tsx (added parent-controlled state support), board-view.tsx (added newFeaturePreviewMap and followUpPreviewMap state, wired up to DescriptionImageDropZone). Image paths were already stored correctly in state - only the preview thumbnails (base64) were lost on tab switch due to component unmounting.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "high"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765363330908-vu0hip87r",
|
||||
"category": "Kanban",
|
||||
"description": "Take a look at waiting aproval column in kanban board and fix the card that render in it u can see in attached images that they text is overlaping check other columns how we have them",
|
||||
"steps": [],
|
||||
"status": "verified",
|
||||
"startedAt": "2025-12-10T10:46:42.494Z",
|
||||
"imagePaths": [
|
||||
{
|
||||
"id": "img-1765363296205-e4cwlj2j8",
|
||||
"path": "/Users/shirone/Library/Application Support/automaker/images/1765363296203-bud50snjv_SCR-20251210-kosr.png",
|
||||
"filename": "SCR-20251210-kosr.png",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
{
|
||||
"id": "img-1765363296214-lloeta38x",
|
||||
"path": "/Users/shirone/Library/Application Support/automaker/images/1765363296212-zg03hi120_SCR-20251210-kotn.png",
|
||||
"filename": "SCR-20251210-kotn.png",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
{
|
||||
"id": "img-1765363296221-oupa9su2f",
|
||||
"path": "/Users/shirone/Library/Application Support/automaker/images/1765363296219-7kndnuzr2_SCR-20251210-koud.png",
|
||||
"filename": "SCR-20251210-koud.png",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
],
|
||||
"skipTests": true,
|
||||
"model": "sonnet",
|
||||
"thinkingLevel": "low"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765365064255-wk51aoz3e",
|
||||
"category": "Core",
|
||||
"description": "I want to have some abbility when executing a task on project to have some type of rewing / checkpoint system so if the changes made by agent in the project dont satisfy me / break something i can click in the ui to revert them. The best way for it would be to implement github worktress so when spin up new task claude take a look at it generate new branch that fit task issue and make it as gihub worktree then we would create a a new folder in project .automaker/worktree with branch name and clone of repo so agent can freely work one something like that ",
|
||||
"steps": [],
|
||||
"status": "waiting_approval",
|
||||
"startedAt": "2025-12-10T11:11:06.115Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Implemented Git Worktree Checkpoint/Revert System. Created: worktree-manager.js service. Modified: auto-mode-service.js (worktree integration, revert/merge methods), feature-loader.js (worktree tracking), main.js (IPC handlers), preload.js (API exposure), app-store.ts (Feature type), electron.d.ts (types), electron.ts (mock API), kanban-card.tsx (branch badge, revert/merge buttons), board-view.tsx (handlers). Features: isolated git branches per feature, branch badge on cards, revert changes button, merge to main button, file diff APIs.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "ultrathink"
|
||||
},
|
||||
{
|
||||
"id": "feature-1765365411551-87ww2zw6g",
|
||||
"category": "Kanban",
|
||||
"description": "When a agent is workig on task or when its in waiting approval column its would be nice to have some type of git diff panel and see what files got changed as well as reusing our custom themes we have in settings for the editor view of it take a look at codebase and create implementation for it",
|
||||
"steps": [],
|
||||
"status": "waiting_approval",
|
||||
"startedAt": "2025-12-10T11:16:54.069Z",
|
||||
"imagePaths": [],
|
||||
"skipTests": true,
|
||||
"summary": "Added git diff panel for in-progress and waiting approval features. Created GitDiffPanel component with themed syntax highlighting. Modified: git-diff-panel.tsx (new), agent-output-modal.tsx, worktree-manager.js, auto-mode-service.js, main.js, preload.js, electron.d.ts. The panel shows changed files with +/- stats and expandable unified diff view using CSS theme variables.",
|
||||
"model": "opus",
|
||||
"thinkingLevel": "ultrathink"
|
||||
}
|
||||
]
|
||||
@@ -3,6 +3,7 @@ const featureExecutor = require("./services/feature-executor");
|
||||
const featureVerifier = require("./services/feature-verifier");
|
||||
const contextManager = require("./services/context-manager");
|
||||
const projectAnalyzer = require("./services/project-analyzer");
|
||||
const worktreeManager = require("./services/worktree-manager");
|
||||
|
||||
/**
|
||||
* Auto Mode Service - Autonomous feature implementation
|
||||
@@ -33,13 +34,68 @@ class AutoModeService {
|
||||
const context = {
|
||||
abortController: null,
|
||||
query: null,
|
||||
projectPath: null,
|
||||
projectPath: null, // Original project path
|
||||
worktreePath: null, // Path to worktree (where agent works)
|
||||
branchName: null, // Feature branch name
|
||||
sendToRenderer: null,
|
||||
isActive: () => this.runningFeatures.has(featureId),
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup worktree for a feature
|
||||
* Creates an isolated git worktree where the agent can work
|
||||
*/
|
||||
async setupWorktreeForFeature(feature, projectPath, sendToRenderer) {
|
||||
// Check if worktrees are enabled (project must be a git repo)
|
||||
const isGit = await worktreeManager.isGitRepo(projectPath);
|
||||
if (!isGit) {
|
||||
console.log(`[AutoMode] Project is not a git repo, skipping worktree creation`);
|
||||
return { useWorktree: false, workPath: projectPath };
|
||||
}
|
||||
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: "Creating isolated worktree for feature...\n",
|
||||
});
|
||||
|
||||
const result = await worktreeManager.createWorktree(projectPath, feature);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[AutoMode] Failed to create worktree: ${result.error}. Falling back to main project.`);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: `Warning: Could not create worktree (${result.error}). Working directly on main project.\n`,
|
||||
});
|
||||
return { useWorktree: false, workPath: projectPath };
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Created worktree at: ${result.worktreePath}, branch: ${result.branchName}`);
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: feature.id,
|
||||
content: `Working in isolated branch: ${result.branchName}\n`,
|
||||
});
|
||||
|
||||
// Update feature with worktree info in feature_list.json
|
||||
await featureLoader.updateFeatureWorktree(
|
||||
feature.id,
|
||||
projectPath,
|
||||
result.worktreePath,
|
||||
result.branchName
|
||||
);
|
||||
|
||||
return {
|
||||
useWorktree: true,
|
||||
workPath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
baseBranch: result.baseBranch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto mode - continuously implement features
|
||||
*/
|
||||
@@ -134,6 +190,14 @@ class AutoModeService {
|
||||
|
||||
console.log(`[AutoMode] Running feature: ${feature.description}`);
|
||||
|
||||
// Setup worktree for isolated work
|
||||
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer);
|
||||
execution.worktreePath = worktreeSetup.workPath;
|
||||
execution.branchName = worktreeSetup.branchName;
|
||||
|
||||
// Determine working path (worktree or main project)
|
||||
const workPath = worktreeSetup.workPath;
|
||||
|
||||
// Update feature status to in_progress
|
||||
await featureLoader.updateFeatureStatus(
|
||||
featureId,
|
||||
@@ -144,24 +208,27 @@ class AutoModeService {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_start",
|
||||
featureId: feature.id,
|
||||
feature: feature,
|
||||
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
||||
});
|
||||
|
||||
// Implement the feature
|
||||
// Implement the feature (agent works in worktree)
|
||||
const result = await featureExecutor.implementFeature(
|
||||
feature,
|
||||
projectPath,
|
||||
workPath, // Use worktree path instead of main project
|
||||
sendToRenderer,
|
||||
execution
|
||||
);
|
||||
|
||||
// Update feature status based on result
|
||||
// For skipTests features, go to waiting_approval on success instead of verified
|
||||
// On failure, skipTests features should also go to waiting_approval for user review
|
||||
let newStatus;
|
||||
if (result.passes) {
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
||||
} else {
|
||||
newStatus = "backlog";
|
||||
// For skipTests features, keep in waiting_approval so user can review
|
||||
// For normal TDD features, move to backlog for retry
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
|
||||
}
|
||||
await featureLoader.updateFeatureStatus(
|
||||
feature.id,
|
||||
@@ -575,6 +642,14 @@ class AutoModeService {
|
||||
execution.sendToRenderer = sendToRenderer;
|
||||
this.runningFeatures.set(featureId, execution);
|
||||
|
||||
// Setup worktree for isolated work
|
||||
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer);
|
||||
execution.worktreePath = worktreeSetup.workPath;
|
||||
execution.branchName = worktreeSetup.branchName;
|
||||
|
||||
// Determine working path (worktree or main project)
|
||||
const workPath = worktreeSetup.workPath;
|
||||
|
||||
// Update status to in_progress with timestamp
|
||||
await featureLoader.updateFeatureStatus(
|
||||
featureId,
|
||||
@@ -585,23 +660,27 @@ class AutoModeService {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_start",
|
||||
featureId: feature.id,
|
||||
feature: feature,
|
||||
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
|
||||
});
|
||||
|
||||
// Implement the feature (this runs async in background)
|
||||
// Implement the feature (agent works in worktree)
|
||||
const result = await featureExecutor.implementFeature(
|
||||
feature,
|
||||
projectPath,
|
||||
workPath, // Use worktree path instead of main project
|
||||
sendToRenderer,
|
||||
execution
|
||||
);
|
||||
|
||||
// Update feature status based on result
|
||||
// For skipTests features, go to waiting_approval on success instead of verified
|
||||
// On failure, skipTests features should also go to waiting_approval for user review
|
||||
let newStatus;
|
||||
if (result.passes) {
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "verified";
|
||||
} else {
|
||||
newStatus = "backlog";
|
||||
// For skipTests features, keep in waiting_approval so user can review
|
||||
// For normal TDD features, move to backlog for retry
|
||||
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
|
||||
}
|
||||
await featureLoader.updateFeatureStatus(
|
||||
feature.id,
|
||||
@@ -975,6 +1054,170 @@ class AutoModeService {
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert feature changes by removing the worktree
|
||||
* This effectively discards all changes made by the agent
|
||||
*/
|
||||
async revertFeature({ projectPath, featureId, sendToRenderer }) {
|
||||
console.log(`[AutoMode] Reverting feature: ${featureId}`);
|
||||
|
||||
try {
|
||||
// Stop the feature if it's running
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
await this.stopFeature({ featureId });
|
||||
}
|
||||
|
||||
// Remove the worktree and delete the branch
|
||||
const result = await worktreeManager.removeWorktree(projectPath, featureId, true);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to remove worktree");
|
||||
}
|
||||
|
||||
// Clear worktree info from feature
|
||||
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
||||
|
||||
// Update feature status back to backlog
|
||||
await featureLoader.updateFeatureStatus(featureId, "backlog", projectPath);
|
||||
|
||||
// Delete context file
|
||||
await contextManager.deleteContextFile(projectPath, featureId);
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_complete",
|
||||
featureId: featureId,
|
||||
passes: false,
|
||||
message: "Feature reverted - all changes discarded",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Feature ${featureId} reverted successfully`);
|
||||
return { success: true, removedPath: result.removedPath };
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error reverting feature:", error);
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
error: error.message,
|
||||
featureId: featureId,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge feature worktree changes back to main branch
|
||||
*/
|
||||
async mergeFeature({ projectPath, featureId, options = {}, sendToRenderer }) {
|
||||
console.log(`[AutoMode] Merging feature: ${featureId}`);
|
||||
|
||||
try {
|
||||
// Load feature to get worktree info
|
||||
const features = await featureLoader.loadFeatures(projectPath);
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_progress",
|
||||
featureId: featureId,
|
||||
content: "Merging feature branch into main...\n",
|
||||
});
|
||||
}
|
||||
|
||||
// Merge the worktree
|
||||
const result = await worktreeManager.mergeWorktree(projectPath, featureId, {
|
||||
...options,
|
||||
cleanup: true, // Remove worktree after successful merge
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to merge worktree");
|
||||
}
|
||||
|
||||
// Clear worktree info from feature
|
||||
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
|
||||
|
||||
// Update feature status to verified
|
||||
await featureLoader.updateFeatureStatus(featureId, "verified", projectPath);
|
||||
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_feature_complete",
|
||||
featureId: featureId,
|
||||
passes: true,
|
||||
message: `Feature merged into ${result.intoBranch}`,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Feature ${featureId} merged successfully`);
|
||||
return { success: true, mergedBranch: result.mergedBranch };
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Error merging feature:", error);
|
||||
if (sendToRenderer) {
|
||||
sendToRenderer({
|
||||
type: "auto_mode_error",
|
||||
error: error.message,
|
||||
featureId: featureId,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
async getWorktreeInfo({ projectPath, featureId }) {
|
||||
return await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree status (changed files, commits, etc.)
|
||||
*/
|
||||
async getWorktreeStatus({ projectPath, featureId }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getWorktreeStatus(worktreeInfo.worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all feature worktrees
|
||||
*/
|
||||
async listWorktrees({ projectPath }) {
|
||||
const worktrees = await worktreeManager.getAllFeatureWorktrees(projectPath);
|
||||
return { success: true, worktrees };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file diffs for a feature worktree
|
||||
*/
|
||||
async getFileDiffs({ projectPath, featureId }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getFileDiffs(worktreeInfo.worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a feature worktree
|
||||
*/
|
||||
async getFileDiff({ projectPath, featureId, filePath }) {
|
||||
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
return await worktreeManager.getFileDiff(worktreeInfo.worktreePath, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -803,3 +803,134 @@ ipcMain.handle("openai:test-connection", async (_, { apiKey }) => {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Worktree Management IPC Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Revert feature changes by removing the worktree
|
||||
* This effectively discards all changes made by the agent
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:revert-feature",
|
||||
async (_, { projectPath, featureId }) => {
|
||||
console.log("[IPC] worktree:revert-feature called with:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
});
|
||||
try {
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
return await autoModeService.revertFeature({
|
||||
projectPath,
|
||||
featureId,
|
||||
sendToRenderer,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:revert-feature error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merge feature worktree changes back to main branch
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:merge-feature",
|
||||
async (_, { projectPath, featureId, options }) => {
|
||||
console.log("[IPC] worktree:merge-feature called with:", {
|
||||
projectPath,
|
||||
featureId,
|
||||
options,
|
||||
});
|
||||
try {
|
||||
const sendToRenderer = (data) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("auto-mode:event", data);
|
||||
}
|
||||
};
|
||||
|
||||
return await autoModeService.mergeFeature({
|
||||
projectPath,
|
||||
featureId,
|
||||
options,
|
||||
sendToRenderer,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:merge-feature error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:get-info",
|
||||
async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getWorktreeInfo({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-info error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get worktree status (changed files, commits)
|
||||
*/
|
||||
ipcMain.handle(
|
||||
"worktree:get-status",
|
||||
async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getWorktreeStatus({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-status error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* List all feature worktrees
|
||||
*/
|
||||
ipcMain.handle("worktree:list", async (_, { projectPath }) => {
|
||||
try {
|
||||
return await autoModeService.listWorktrees({ projectPath });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:list error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get file diffs for a worktree
|
||||
*/
|
||||
ipcMain.handle("worktree:get-diffs", async (_, { projectPath, featureId }) => {
|
||||
try {
|
||||
return await autoModeService.getFileDiffs({ projectPath, featureId });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-diffs error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a worktree
|
||||
*/
|
||||
ipcMain.handle("worktree:get-file-diff", async (_, { projectPath, featureId, filePath }) => {
|
||||
try {
|
||||
return await autoModeService.getFileDiff({ projectPath, featureId, filePath });
|
||||
} catch (error) {
|
||||
console.error("[IPC] worktree:get-file-diff error:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -158,6 +158,37 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// OpenAI API
|
||||
testOpenAIConnection: (apiKey) =>
|
||||
ipcRenderer.invoke("openai:test-connection", { apiKey }),
|
||||
|
||||
// Worktree Management APIs
|
||||
worktree: {
|
||||
// Revert feature changes by removing the worktree
|
||||
revertFeature: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }),
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
mergeFeature: (projectPath, featureId, options) =>
|
||||
ipcRenderer.invoke("worktree:merge-feature", { projectPath, featureId, options }),
|
||||
|
||||
// Get worktree info for a feature
|
||||
getInfo: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }),
|
||||
|
||||
// Get worktree status (changed files, commits)
|
||||
getStatus: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
|
||||
|
||||
// List all feature worktrees
|
||||
list: (projectPath) =>
|
||||
ipcRenderer.invoke("worktree:list", { projectPath }),
|
||||
|
||||
// Get file diffs for a feature worktree
|
||||
getDiffs: (projectPath, featureId) =>
|
||||
ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }),
|
||||
|
||||
// Get diff for a specific file in a worktree
|
||||
getFileDiff: (projectPath, featureId, filePath) =>
|
||||
ipcRenderer.invoke("worktree:get-file-diff", { projectPath, featureId, filePath }),
|
||||
},
|
||||
});
|
||||
|
||||
// Also expose a flag to detect if we're in Electron
|
||||
|
||||
@@ -141,6 +141,13 @@ class FeatureLoader {
|
||||
if (f.error !== undefined) {
|
||||
featureData.error = f.error;
|
||||
}
|
||||
// Preserve worktree info
|
||||
if (f.worktreePath !== undefined) {
|
||||
featureData.worktreePath = f.worktreePath;
|
||||
}
|
||||
if (f.branchName !== undefined) {
|
||||
featureData.branchName = f.branchName;
|
||||
}
|
||||
return featureData;
|
||||
});
|
||||
|
||||
@@ -163,6 +170,69 @@ class FeatureLoader {
|
||||
// Skip verified and waiting_approval (which needs user input)
|
||||
return features.find((f) => f.status !== "verified" && f.status !== "waiting_approval");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update worktree info for a feature
|
||||
* @param {string} featureId - The ID of the feature to update
|
||||
* @param {string} projectPath - Path to the project
|
||||
* @param {string|null} worktreePath - Path to the worktree (null to clear)
|
||||
* @param {string|null} branchName - Name of the feature branch (null to clear)
|
||||
*/
|
||||
async updateFeatureWorktree(featureId, projectPath, worktreePath, branchName) {
|
||||
const featuresPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"feature_list.json"
|
||||
);
|
||||
|
||||
const features = await this.loadFeatures(projectPath);
|
||||
|
||||
if (!Array.isArray(features) || features.length === 0) {
|
||||
console.error("[FeatureLoader] Cannot update worktree: feature list is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
|
||||
if (!feature) {
|
||||
console.error(`[FeatureLoader] Feature ${featureId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update or clear worktree info
|
||||
if (worktreePath) {
|
||||
feature.worktreePath = worktreePath;
|
||||
feature.branchName = branchName;
|
||||
} else {
|
||||
delete feature.worktreePath;
|
||||
delete feature.branchName;
|
||||
}
|
||||
|
||||
// Save back to file (reuse the same mapping logic)
|
||||
const toSave = features.map((f) => {
|
||||
const featureData = {
|
||||
id: f.id,
|
||||
category: f.category,
|
||||
description: f.description,
|
||||
steps: f.steps,
|
||||
status: f.status,
|
||||
};
|
||||
if (f.skipTests !== undefined) featureData.skipTests = f.skipTests;
|
||||
if (f.images !== undefined) featureData.images = f.images;
|
||||
if (f.imagePaths !== undefined) featureData.imagePaths = f.imagePaths;
|
||||
if (f.startedAt !== undefined) featureData.startedAt = f.startedAt;
|
||||
if (f.summary !== undefined) featureData.summary = f.summary;
|
||||
if (f.model !== undefined) featureData.model = f.model;
|
||||
if (f.thinkingLevel !== undefined) featureData.thinkingLevel = f.thinkingLevel;
|
||||
if (f.error !== undefined) featureData.error = f.error;
|
||||
if (f.worktreePath !== undefined) featureData.worktreePath = f.worktreePath;
|
||||
if (f.branchName !== undefined) featureData.branchName = f.branchName;
|
||||
return featureData;
|
||||
});
|
||||
|
||||
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
console.log(`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeatureLoader();
|
||||
|
||||
576
app/electron/services/worktree-manager.js
Normal file
576
app/electron/services/worktree-manager.js
Normal file
@@ -0,0 +1,576 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs/promises");
|
||||
const { exec, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Worktree Manager - Handles git worktrees for feature isolation
|
||||
*
|
||||
* This service creates isolated git worktrees for each feature, allowing:
|
||||
* - Features to be worked on in isolation without affecting the main branch
|
||||
* - Easy rollback/revert by simply deleting the worktree
|
||||
* - Checkpointing - user can see changes in the worktree before merging
|
||||
*/
|
||||
class WorktreeManager {
|
||||
constructor() {
|
||||
// Cache for worktree info
|
||||
this.worktreeCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base worktree directory path
|
||||
*/
|
||||
getWorktreeBasePath(projectPath) {
|
||||
return path.join(projectPath, ".automaker", "worktrees");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a safe branch name from feature description
|
||||
*/
|
||||
generateBranchName(feature) {
|
||||
// Create a slug from the description
|
||||
const slug = feature.description
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.substring(0, 40); // Limit length
|
||||
|
||||
// Add feature ID for uniqueness
|
||||
const shortId = feature.id.replace("feature-", "").substring(0, 12);
|
||||
return `feature/${shortId}-${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the project is a git repository
|
||||
*/
|
||||
async isGitRepo(projectPath) {
|
||||
try {
|
||||
await execAsync("git rev-parse --is-inside-work-tree", { cwd: projectPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current branch name
|
||||
*/
|
||||
async getCurrentBranch(projectPath) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: projectPath });
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get current branch:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch exists (local or remote)
|
||||
*/
|
||||
async branchExists(projectPath, branchName) {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all existing worktrees
|
||||
*/
|
||||
async listWorktrees(projectPath) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath });
|
||||
const worktrees = [];
|
||||
const lines = stdout.split("\n");
|
||||
|
||||
let currentWorktree = null;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
if (currentWorktree) {
|
||||
worktrees.push(currentWorktree);
|
||||
}
|
||||
currentWorktree = { path: line.replace("worktree ", "") };
|
||||
} else if (line.startsWith("branch ") && currentWorktree) {
|
||||
currentWorktree.branch = line.replace("branch refs/heads/", "");
|
||||
} else if (line.startsWith("HEAD ") && currentWorktree) {
|
||||
currentWorktree.head = line.replace("HEAD ", "");
|
||||
}
|
||||
}
|
||||
if (currentWorktree) {
|
||||
worktrees.push(currentWorktree);
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to list worktrees:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree for a feature
|
||||
* @param {string} projectPath - Path to the main project
|
||||
* @param {object} feature - Feature object with id and description
|
||||
* @returns {object} - { success, worktreePath, branchName, error }
|
||||
*/
|
||||
async createWorktree(projectPath, feature) {
|
||||
console.log(`[WorktreeManager] Creating worktree for feature: ${feature.id}`);
|
||||
|
||||
// Check if project is a git repo
|
||||
if (!await this.isGitRepo(projectPath)) {
|
||||
return { success: false, error: "Project is not a git repository" };
|
||||
}
|
||||
|
||||
const branchName = this.generateBranchName(feature);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
const worktreePath = path.join(worktreeBasePath, branchName.replace("feature/", ""));
|
||||
|
||||
try {
|
||||
// Ensure worktree directory exists
|
||||
await fs.mkdir(worktreeBasePath, { recursive: true });
|
||||
|
||||
// Check if worktree already exists
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const existingWorktree = worktrees.find(
|
||||
w => w.path === worktreePath || w.branch === branchName
|
||||
);
|
||||
|
||||
if (existingWorktree) {
|
||||
console.log(`[WorktreeManager] Worktree already exists for feature: ${feature.id}`);
|
||||
return {
|
||||
success: true,
|
||||
worktreePath: existingWorktree.path,
|
||||
branchName: existingWorktree.branch,
|
||||
existed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Get current branch to base the new branch on
|
||||
const baseBranch = await this.getCurrentBranch(projectPath);
|
||||
if (!baseBranch) {
|
||||
return { success: false, error: "Could not determine current branch" };
|
||||
}
|
||||
|
||||
// Check if branch already exists
|
||||
const branchExists = await this.branchExists(projectPath, branchName);
|
||||
|
||||
if (branchExists) {
|
||||
// Use existing branch
|
||||
console.log(`[WorktreeManager] Using existing branch: ${branchName}`);
|
||||
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectPath });
|
||||
} else {
|
||||
// Create new worktree with new branch
|
||||
console.log(`[WorktreeManager] Creating new branch: ${branchName} based on ${baseBranch}`);
|
||||
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
// Copy .automaker directory to worktree (except worktrees directory itself to avoid recursion)
|
||||
const automakerSrc = path.join(projectPath, ".automaker");
|
||||
const automakerDst = path.join(worktreePath, ".automaker");
|
||||
|
||||
try {
|
||||
await fs.mkdir(automakerDst, { recursive: true });
|
||||
|
||||
// Copy feature_list.json
|
||||
const featureListSrc = path.join(automakerSrc, "feature_list.json");
|
||||
const featureListDst = path.join(automakerDst, "feature_list.json");
|
||||
try {
|
||||
const content = await fs.readFile(featureListSrc, "utf-8");
|
||||
await fs.writeFile(featureListDst, content, "utf-8");
|
||||
} catch {
|
||||
// Feature list might not exist yet
|
||||
}
|
||||
|
||||
// Copy app_spec.txt if it exists
|
||||
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");
|
||||
const appSpecDst = path.join(automakerDst, "app_spec.txt");
|
||||
try {
|
||||
const content = await fs.readFile(appSpecSrc, "utf-8");
|
||||
await fs.writeFile(appSpecDst, content, "utf-8");
|
||||
} catch {
|
||||
// App spec might not exist yet
|
||||
}
|
||||
|
||||
// Copy categories.json if it exists
|
||||
const categoriesSrc = path.join(automakerSrc, "categories.json");
|
||||
const categoriesDst = path.join(automakerDst, "categories.json");
|
||||
try {
|
||||
const content = await fs.readFile(categoriesSrc, "utf-8");
|
||||
await fs.writeFile(categoriesDst, content, "utf-8");
|
||||
} catch {
|
||||
// Categories might not exist yet
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[WorktreeManager] Failed to copy .automaker directory:", error);
|
||||
}
|
||||
|
||||
// Store worktree info in cache
|
||||
this.worktreeCache.set(feature.id, {
|
||||
worktreePath,
|
||||
branchName,
|
||||
createdAt: new Date().toISOString(),
|
||||
baseBranch,
|
||||
});
|
||||
|
||||
console.log(`[WorktreeManager] Worktree created at: ${worktreePath}`);
|
||||
return {
|
||||
success: true,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
existed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to create worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree info for a feature
|
||||
*/
|
||||
async getWorktreeInfo(projectPath, featureId) {
|
||||
// Check cache first
|
||||
if (this.worktreeCache.has(featureId)) {
|
||||
return { success: true, ...this.worktreeCache.get(featureId) };
|
||||
}
|
||||
|
||||
// Scan worktrees to find matching one
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
// Check if this worktree is in our worktree directory
|
||||
if (worktree.path.startsWith(worktreeBasePath)) {
|
||||
// Check if the feature ID is in the branch name
|
||||
const shortId = featureId.replace("feature-", "").substring(0, 12);
|
||||
if (worktree.branch && worktree.branch.includes(shortId)) {
|
||||
const info = {
|
||||
worktreePath: worktree.path,
|
||||
branchName: worktree.branch,
|
||||
head: worktree.head,
|
||||
};
|
||||
this.worktreeCache.set(featureId, info);
|
||||
return { success: true, ...info };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree for a feature
|
||||
* This effectively reverts all changes made by the agent
|
||||
*/
|
||||
async removeWorktree(projectPath, featureId, deleteBranch = false) {
|
||||
console.log(`[WorktreeManager] Removing worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
console.log(`[WorktreeManager] No worktree found for feature: ${featureId}`);
|
||||
return { success: true, message: "No worktree to remove" };
|
||||
}
|
||||
|
||||
const { worktreePath, branchName } = worktreeInfo;
|
||||
|
||||
try {
|
||||
// Remove the worktree
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
|
||||
console.log(`[WorktreeManager] Worktree removed: ${worktreePath}`);
|
||||
|
||||
// Optionally delete the branch too
|
||||
if (deleteBranch && branchName) {
|
||||
try {
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
console.log(`[WorktreeManager] Branch deleted: ${branchName}`);
|
||||
} catch (error) {
|
||||
console.warn(`[WorktreeManager] Could not delete branch ${branchName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
this.worktreeCache.delete(featureId);
|
||||
|
||||
return { success: true, removedPath: worktreePath, removedBranch: deleteBranch ? branchName : null };
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to remove worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of changes in a worktree
|
||||
*/
|
||||
async getWorktreeStatus(worktreePath) {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
const { stdout: diffStat } = await execAsync("git diff --stat", { cwd: worktreePath });
|
||||
const { stdout: commitLog } = await execAsync("git log --oneline -10", { cwd: worktreePath });
|
||||
|
||||
const files = statusOutput.trim().split("\n").filter(Boolean);
|
||||
const commits = commitLog.trim().split("\n").filter(Boolean);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modifiedFiles: files.length,
|
||||
files: files.slice(0, 20), // Limit to 20 files
|
||||
diffStat: diffStat.trim(),
|
||||
recentCommits: commits.slice(0, 5), // Last 5 commits
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get worktree status:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed file diff content for a worktree
|
||||
* Returns unified diff format for all changes
|
||||
*/
|
||||
async getFileDiffs(worktreePath) {
|
||||
try {
|
||||
// Get both staged and unstaged diffs
|
||||
const { stdout: unstagedDiff } = await execAsync("git diff --no-color", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large diffs
|
||||
});
|
||||
const { stdout: stagedDiff } = await execAsync("git diff --cached --no-color", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024
|
||||
});
|
||||
|
||||
// Get list of files with their status
|
||||
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
const files = statusOutput.trim().split("\n").filter(Boolean);
|
||||
|
||||
// Parse file statuses
|
||||
const fileStatuses = files.map(line => {
|
||||
const status = line.substring(0, 2);
|
||||
const filePath = line.substring(3);
|
||||
return {
|
||||
status: status.trim() || 'M',
|
||||
path: filePath,
|
||||
statusText: this.getStatusText(status)
|
||||
};
|
||||
});
|
||||
|
||||
// Combine diffs
|
||||
const combinedDiff = [stagedDiff, unstagedDiff].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
diff: combinedDiff,
|
||||
files: fileStatuses,
|
||||
hasChanges: files.length > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to get file diffs:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status text from git status code
|
||||
*/
|
||||
getStatusText(status) {
|
||||
const statusMap = {
|
||||
'M': 'Modified',
|
||||
'A': 'Added',
|
||||
'D': 'Deleted',
|
||||
'R': 'Renamed',
|
||||
'C': 'Copied',
|
||||
'U': 'Updated',
|
||||
'?': 'Untracked',
|
||||
'!': 'Ignored'
|
||||
};
|
||||
const firstChar = status.charAt(0);
|
||||
const secondChar = status.charAt(1);
|
||||
return statusMap[firstChar] || statusMap[secondChar] || 'Changed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a specific file in a worktree
|
||||
*/
|
||||
async getFileDiff(worktreePath, filePath) {
|
||||
try {
|
||||
// Try to get unstaged diff first, then staged if no unstaged changes
|
||||
let diff = '';
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --no-color -- "${filePath}"`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 5 * 1024 * 1024
|
||||
});
|
||||
diff = stdout;
|
||||
} catch {
|
||||
// File might be staged
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git diff --cached --no-color -- "${filePath}"`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 5 * 1024 * 1024
|
||||
});
|
||||
diff = stdout;
|
||||
} catch {
|
||||
// File might be untracked, show the content
|
||||
}
|
||||
}
|
||||
|
||||
// If still no diff, might be an untracked file - show the content
|
||||
if (!diff) {
|
||||
try {
|
||||
const fullPath = path.join(worktreePath, filePath);
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
diff = `+++ ${filePath} (new file)\n${content.split('\n').map(l => '+' + l).join('\n')}`;
|
||||
} catch {
|
||||
diff = '(Unable to read file content)';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
diff,
|
||||
filePath
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[WorktreeManager] Failed to get diff for ${filePath}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge worktree changes back to the main branch
|
||||
*/
|
||||
async mergeWorktree(projectPath, featureId, options = {}) {
|
||||
console.log(`[WorktreeManager] Merging worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
const { branchName, worktreePath } = worktreeInfo;
|
||||
const baseBranch = await this.getCurrentBranch(projectPath);
|
||||
|
||||
try {
|
||||
// First commit any uncommitted changes in the worktree
|
||||
const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath });
|
||||
if (status.trim()) {
|
||||
// There are uncommitted changes - commit them
|
||||
await execAsync("git add -A", { cwd: worktreePath });
|
||||
const commitMsg = options.commitMessage || `feat: complete ${featureId}`;
|
||||
await execAsync(`git commit -m "${commitMsg}"`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Merge the feature branch into the current branch in the main repo
|
||||
if (options.squash) {
|
||||
await execAsync(`git merge --squash ${branchName}`, { cwd: projectPath });
|
||||
const squashMsg = options.squashMessage || `feat: ${featureId} - squashed merge`;
|
||||
await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath });
|
||||
} else {
|
||||
await execAsync(`git merge ${branchName} --no-ff -m "Merge ${branchName}"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
console.log(`[WorktreeManager] Successfully merged ${branchName} into ${baseBranch}`);
|
||||
|
||||
// Optionally cleanup worktree after merge
|
||||
if (options.cleanup) {
|
||||
await this.removeWorktree(projectPath, featureId, true);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mergedBranch: branchName,
|
||||
intoBranch: baseBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to merge worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync changes from main branch to worktree (rebase or merge)
|
||||
*/
|
||||
async syncWorktree(projectPath, featureId, method = "rebase") {
|
||||
console.log(`[WorktreeManager] Syncing worktree for feature: ${featureId}`);
|
||||
|
||||
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
|
||||
if (!worktreeInfo.success) {
|
||||
return { success: false, error: "Worktree not found" };
|
||||
}
|
||||
|
||||
const { worktreePath, baseBranch } = worktreeInfo;
|
||||
|
||||
try {
|
||||
if (method === "rebase") {
|
||||
await execAsync(`git rebase ${baseBranch}`, { cwd: worktreePath });
|
||||
} else {
|
||||
await execAsync(`git merge ${baseBranch}`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
return { success: true, method };
|
||||
} catch (error) {
|
||||
console.error("[WorktreeManager] Failed to sync worktree:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all feature worktrees
|
||||
*/
|
||||
async getAllFeatureWorktrees(projectPath) {
|
||||
const worktrees = await this.listWorktrees(projectPath);
|
||||
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
|
||||
|
||||
return worktrees.filter(w =>
|
||||
w.path.startsWith(worktreeBasePath) &&
|
||||
w.branch &&
|
||||
w.branch.startsWith("feature/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup orphaned worktrees (worktrees without matching features)
|
||||
*/
|
||||
async cleanupOrphanedWorktrees(projectPath, activeFeatureIds) {
|
||||
console.log("[WorktreeManager] Cleaning up orphaned worktrees...");
|
||||
|
||||
const worktrees = await this.getAllFeatureWorktrees(projectPath);
|
||||
const cleaned = [];
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
// Extract feature ID from branch name
|
||||
const branchParts = worktree.branch.replace("feature/", "").split("-");
|
||||
const shortId = branchParts[0];
|
||||
|
||||
// Check if any active feature has this short ID
|
||||
const hasMatchingFeature = activeFeatureIds.some(id => {
|
||||
const featureShortId = id.replace("feature-", "").substring(0, 12);
|
||||
return featureShortId === shortId;
|
||||
});
|
||||
|
||||
if (!hasMatchingFeature) {
|
||||
console.log(`[WorktreeManager] Removing orphaned worktree: ${worktree.path}`);
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktree.path}" --force`, { cwd: projectPath });
|
||||
await execAsync(`git branch -D ${worktree.branch}`, { cwd: projectPath });
|
||||
cleaned.push(worktree.path);
|
||||
} catch (error) {
|
||||
console.warn(`[WorktreeManager] Failed to cleanup worktree ${worktree.path}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, cleaned };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorktreeManager();
|
||||
571
app/src/components/ui/git-diff-panel.tsx
Normal file
571
app/src/components/ui/git-diff-panel.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import type { FileStatus } from "@/types/electron";
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
className?: string;
|
||||
/** Whether to show the panel in a compact/minimized state initially */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedFileDiff {
|
||||
filePath: string;
|
||||
hunks: ParsedDiffHunk[];
|
||||
isNew?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isRenamed?: boolean;
|
||||
}
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return <FilePlus className="w-4 h-4 text-green-500" />;
|
||||
case "D":
|
||||
return <FileX className="w-4 h-4 text-red-500" />;
|
||||
case "M":
|
||||
case "U":
|
||||
return <FilePen className="w-4 h-4 text-amber-500" />;
|
||||
case "R":
|
||||
case "C":
|
||||
return <File className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "A":
|
||||
case "?":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "D":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "M":
|
||||
case "U":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "R":
|
||||
case "C":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse unified diff format into structured data
|
||||
*/
|
||||
function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split("\n");
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
let newLineNum = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// New file diff
|
||||
if (line.startsWith("diff --git")) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
// Extract file path from diff header
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : "unknown",
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file indicator
|
||||
if (line.startsWith("new file mode")) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file indicator
|
||||
if (line.startsWith("deleted file mode")) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Renamed file indicator
|
||||
if (line.startsWith("rename from") || line.startsWith("rename to")) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip index, ---/+++ lines
|
||||
if (
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith("@@")) {
|
||||
if (currentHunk && currentFile) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
// Parse line numbers from @@ -old,count +new,count @@
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: "header", content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diff content lines
|
||||
if (currentHunk) {
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({
|
||||
type: "addition",
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({
|
||||
type: "deletion",
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
currentHunk.lines.push({
|
||||
type: "context",
|
||||
content: line.substring(1) || "",
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last file and hunk
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
files.push(currentFile);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: "bg-transparent",
|
||||
addition: "bg-green-500/10",
|
||||
deletion: "bg-red-500/10",
|
||||
header: "bg-blue-500/10",
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: "text-foreground-secondary",
|
||||
addition: "text-green-400",
|
||||
deletion: "text-red-400",
|
||||
header: "text-blue-400",
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: " ",
|
||||
addition: "+",
|
||||
deletion: "-",
|
||||
header: "",
|
||||
};
|
||||
|
||||
if (type === "header") {
|
||||
return (
|
||||
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex font-mono text-xs", bgClass[type])}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.old ?? ""}
|
||||
</span>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.new ?? ""}
|
||||
</span>
|
||||
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
|
||||
{content || "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffSection({
|
||||
fileDiff,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
fileDiff: ParsedFileDiff;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
);
|
||||
const deletions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="flex-1 text-sm font-mono truncate text-foreground">
|
||||
{fileDiff.filePath}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{fileDiff.isNew && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isDeleted && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
deleted
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.isRenamed && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
|
||||
renamed
|
||||
</span>
|
||||
)}
|
||||
{additions > 0 && (
|
||||
<span className="text-xs text-green-400">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-xs text-red-400">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto">
|
||||
{fileDiff.hunks.map((hunk, hunkIndex) => (
|
||||
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
|
||||
{hunk.lines.map((line, lineIndex) => (
|
||||
<DiffLine
|
||||
key={lineIndex}
|
||||
type={line.type}
|
||||
content={line.content}
|
||||
lineNumber={line.lineNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitDiffPanel({
|
||||
projectPath,
|
||||
featureId,
|
||||
className,
|
||||
compact = true,
|
||||
}: GitDiffPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState<string>("");
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadDiffs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
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");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load diffs");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, featureId]);
|
||||
|
||||
// Load diffs when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
loadDiffs();
|
||||
}
|
||||
}, [isExpanded, loadDiffs]);
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
const toggleFile = (filePath: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAllFiles = () => {
|
||||
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
|
||||
};
|
||||
|
||||
const collapseAllFiles = () => {
|
||||
setExpandedFiles(new Set());
|
||||
};
|
||||
|
||||
// Total stats
|
||||
const totalAdditions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
const totalDeletions = parsedDiffs.reduce(
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
data-testid="git-diff-panel"
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left"
|
||||
data-testid="git-diff-panel-toggle"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||
<span className="font-medium text-sm text-foreground">Git Changes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{!isExpanded && files.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">+{totalAdditions}</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<span className="text-sm">No changes detected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Summary bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-1.5"
|
||||
title={file.path}
|
||||
>
|
||||
{getFileIcon(file.status)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded border",
|
||||
getStatusBadgeColor(file.status)
|
||||
)}
|
||||
>
|
||||
{file.statusText}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={expandAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={collapseAllFiles}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? "file" : "files"} changed
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">
|
||||
+{totalAdditions} additions
|
||||
</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">
|
||||
-{totalDeletions} deletions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File diffs */}
|
||||
<div className="space-y-3">
|
||||
{parsedDiffs.map((fileDiff) => (
|
||||
<FileDiffSection
|
||||
key={fileDiff.filePath}
|
||||
fileDiff={fileDiff}
|
||||
isExpanded={expandedFiles.has(fileDiff.filePath)}
|
||||
onToggle={() => toggleFile(fileDiff.filePath)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, List, FileText } from "lucide-react";
|
||||
import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { LogViewer } from "@/components/ui/log-viewer";
|
||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
||||
import type { AutoModeEvent } from "@/types/electron";
|
||||
|
||||
interface AgentOutputModalProps {
|
||||
@@ -22,7 +23,7 @@ interface AgentOutputModalProps {
|
||||
onNumberKeyPress?: (key: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = "parsed" | "raw";
|
||||
type ViewMode = "parsed" | "raw" | "changes";
|
||||
|
||||
export function AgentOutputModal({
|
||||
open,
|
||||
@@ -34,6 +35,7 @@ export function AgentOutputModal({
|
||||
const [output, setOutput] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
||||
const [projectPath, setProjectPath] = useState<string>("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
const projectPathRef = useRef<string>("");
|
||||
@@ -64,6 +66,7 @@ export function AgentOutputModal({
|
||||
}
|
||||
|
||||
projectPathRef.current = currentProject.path;
|
||||
setProjectPath(currentProject.path);
|
||||
|
||||
// Ensure context directory exists
|
||||
const contextDir = `${currentProject.path}/.automaker/agents-context`;
|
||||
@@ -257,7 +260,19 @@ export function AgentOutputModal({
|
||||
data-testid="view-mode-parsed"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
Parsed
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("changes")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
viewMode === "changes"
|
||||
? "bg-primary/20 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
data-testid="view-mode-changes"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("raw")}
|
||||
@@ -281,34 +296,54 @@ export function AgentOutputModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
{viewMode === "changes" ? (
|
||||
<div className="flex-1 overflow-y-auto min-h-[400px] max-h-[60vh]">
|
||||
{projectPath ? (
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
featureId={featureId}
|
||||
compact={false}
|
||||
className="border-0 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
No output yet. The agent will stream output here as it works.
|
||||
</div>
|
||||
) : viewMode === "parsed" ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
{autoScrollRef.current
|
||||
? "Auto-scrolling enabled"
|
||||
: "Scroll to bottom to enable auto-scroll"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1090,6 +1090,106 @@ export function BoardView() {
|
||||
});
|
||||
};
|
||||
|
||||
// Revert feature changes by removing the worktree
|
||||
const handleRevertFeature = async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
console.log("[Board] Reverting feature:", {
|
||||
id: feature.id,
|
||||
description: feature.description,
|
||||
branchName: feature.branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.revertFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Revert not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.revertFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature reverted successfully");
|
||||
// Reload features to update the UI
|
||||
await loadFeatures();
|
||||
toast.success("Feature reverted", {
|
||||
description: `All changes discarded. Moved back to backlog: ${feature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${feature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to revert feature:", result.error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error reverting feature:", error);
|
||||
toast.error("Failed to revert feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
const handleMergeFeature = async (feature: Feature) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
console.log("[Board] Merging feature:", {
|
||||
id: feature.id,
|
||||
description: feature.description,
|
||||
branchName: feature.branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.mergeFeature) {
|
||||
console.error("Worktree API not available");
|
||||
toast.error("Merge not available", {
|
||||
description: "This feature is not available in the current version.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.mergeFeature(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Board] Feature merged successfully");
|
||||
// Reload features to update the UI
|
||||
await loadFeatures();
|
||||
toast.success("Feature merged", {
|
||||
description: `Changes merged to main branch: ${feature.description.slice(
|
||||
0,
|
||||
50
|
||||
)}${feature.description.length > 50 ? "..." : ""}`,
|
||||
});
|
||||
} else {
|
||||
console.error("[Board] Failed to merge feature:", result.error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description: result.error || "An error occurred",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Board] Error merging feature:", error);
|
||||
toast.error("Failed to merge feature", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkContextExists = async (featureId: string): Promise<boolean> => {
|
||||
if (!currentProject) return false;
|
||||
|
||||
@@ -1463,6 +1563,8 @@ export function BoardView() {
|
||||
}
|
||||
onFollowUp={() => handleOpenFollowUp(feature)}
|
||||
onCommit={() => handleCommitFeature(feature)}
|
||||
onRevert={() => handleRevertFeature(feature)}
|
||||
onMerge={() => handleMergeFeature(feature)}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(
|
||||
feature.id
|
||||
|
||||
@@ -49,6 +49,9 @@ import {
|
||||
FileText,
|
||||
MoreVertical,
|
||||
AlertCircle,
|
||||
GitBranch,
|
||||
Undo2,
|
||||
GitMerge,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
@@ -72,6 +75,8 @@ interface KanbanCardProps {
|
||||
onMoveBackToInProgress?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onCommit?: () => void;
|
||||
onRevert?: () => void;
|
||||
onMerge?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -93,6 +98,8 @@ export function KanbanCard({
|
||||
onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onCommit,
|
||||
onRevert,
|
||||
onMerge,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -101,9 +108,13 @@ export function KanbanCard({
|
||||
}: KanbanCardProps) {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
|
||||
// Check if feature has worktree
|
||||
const hasWorktree = !!feature.branchName;
|
||||
|
||||
// Helper functions to check what should be shown based on detail level
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
@@ -246,11 +257,33 @@ export function KanbanCard({
|
||||
<span>Errored</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Branch badge - show when feature has a worktree */}
|
||||
{hasWorktree && !isCurrentAutoTask && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||
// Position below error badge if present, otherwise use normal position
|
||||
feature.error || feature.skipTests
|
||||
? "top-8 left-2"
|
||||
: shortcutKey
|
||||
? "top-2 left-10"
|
||||
: "top-2 left-2"
|
||||
)}
|
||||
data-testid={`branch-badge-${feature.id}`}
|
||||
title={`Branch: ${feature.branchName}`}
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{feature.branchName?.replace("feature/", "")}</span>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader
|
||||
className={cn(
|
||||
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
||||
// Add extra top padding when badges are present to prevent text overlap
|
||||
(feature.skipTests || feature.error || shortcutKey) && "pt-10"
|
||||
(feature.skipTests || feature.error || shortcutKey) && "pt-10",
|
||||
// Add even more top padding when both badges and branch are shown
|
||||
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
|
||||
)}
|
||||
>
|
||||
{isCurrentAutoTask && (
|
||||
@@ -615,6 +648,23 @@ export function KanbanCard({
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||
<>
|
||||
{/* Revert button - only show when worktree exists */}
|
||||
{hasWorktree && onRevert && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRevertDialogOpen(true);
|
||||
}}
|
||||
data-testid={`revert-${feature.id}`}
|
||||
title="Discard all changes and move back to backlog"
|
||||
>
|
||||
<Undo2 className="w-3 h-3 mr-1" />
|
||||
Revert
|
||||
</Button>
|
||||
)}
|
||||
{/* Follow-up prompt button */}
|
||||
{onFollowUp && (
|
||||
<Button
|
||||
@@ -631,8 +681,25 @@ export function KanbanCard({
|
||||
Follow-up
|
||||
</Button>
|
||||
)}
|
||||
{/* Commit and verify button */}
|
||||
{onCommit && (
|
||||
{/* Merge button - only show when worktree exists */}
|
||||
{hasWorktree && onMerge && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMerge();
|
||||
}}
|
||||
data-testid={`merge-${feature.id}`}
|
||||
title="Merge changes into main branch"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 mr-1" />
|
||||
Merge
|
||||
</Button>
|
||||
)}
|
||||
{/* Commit and verify button - show when no worktree */}
|
||||
{!hasWorktree && onCommit && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -736,6 +803,49 @@ export function KanbanCard({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Revert Confirmation Dialog */}
|
||||
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
|
||||
<DialogContent data-testid="revert-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-400">
|
||||
<Undo2 className="w-5 h-5" />
|
||||
Revert Changes
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will discard all changes made by the agent and move the feature back to the backlog.
|
||||
{feature.branchName && (
|
||||
<span className="block mt-2 font-medium">
|
||||
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
|
||||
</span>
|
||||
)}
|
||||
<span className="block mt-2 text-red-400 font-medium">
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsRevertDialogOpen(false)}
|
||||
data-testid="cancel-revert-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setIsRevertDialogOpen(false);
|
||||
onRevert?.();
|
||||
}}
|
||||
data-testid="confirm-revert-button"
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
Revert Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface StatResult {
|
||||
}
|
||||
|
||||
// Auto Mode types - Import from electron.d.ts to avoid duplication
|
||||
import type { AutoModeEvent, ModelDefinition, ProviderStatus } from "@/types/electron";
|
||||
import type { AutoModeEvent, ModelDefinition, ProviderStatus, WorktreeAPI, WorktreeInfo, WorktreeStatus, FileDiffsResult, FileDiffResult, FileStatus } from "@/types/electron";
|
||||
|
||||
export interface AutoModeAPI {
|
||||
start: (projectPath: string, maxConcurrency?: number) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -128,6 +128,7 @@ export interface ElectronAPI {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
worktree?: WorktreeAPI;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -422,9 +423,78 @@ export const getElectronAPI = (): ElectronAPI => {
|
||||
|
||||
// Mock Auto Mode API
|
||||
autoMode: createMockAutoModeAPI(),
|
||||
|
||||
// Mock Worktree API
|
||||
worktree: createMockWorktreeAPI(),
|
||||
};
|
||||
};
|
||||
|
||||
// Mock Worktree API implementation
|
||||
function createMockWorktreeAPI(): WorktreeAPI {
|
||||
return {
|
||||
revertFeature: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Reverting feature:", { projectPath, featureId });
|
||||
return { success: true, removedPath: `/mock/worktree/${featureId}` };
|
||||
},
|
||||
|
||||
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
|
||||
console.log("[Mock] Merging feature:", { projectPath, featureId, options });
|
||||
return { success: true, mergedBranch: `feature/${featureId}` };
|
||||
},
|
||||
|
||||
getInfo: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting worktree info:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
worktreePath: `/mock/worktrees/${featureId}`,
|
||||
branchName: `feature/${featureId}`,
|
||||
head: "abc1234",
|
||||
};
|
||||
},
|
||||
|
||||
getStatus: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting worktree status:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
modifiedFiles: 3,
|
||||
files: ["src/feature.ts", "tests/feature.spec.ts", "README.md"],
|
||||
diffStat: " 3 files changed, 50 insertions(+), 10 deletions(-)",
|
||||
recentCommits: [
|
||||
"abc1234 feat: implement feature",
|
||||
"def5678 test: add tests for feature",
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
list: async (projectPath: string) => {
|
||||
console.log("[Mock] Listing worktrees:", { projectPath });
|
||||
return { success: true, worktrees: [] };
|
||||
},
|
||||
|
||||
getDiffs: async (projectPath: string, featureId: string) => {
|
||||
console.log("[Mock] Getting file diffs:", { projectPath, featureId });
|
||||
return {
|
||||
success: true,
|
||||
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
|
||||
files: [
|
||||
{ status: "A", path: "src/feature.ts", statusText: "Added" },
|
||||
{ status: "M", path: "README.md", statusText: "Modified" },
|
||||
],
|
||||
hasChanges: true,
|
||||
};
|
||||
},
|
||||
|
||||
getFileDiff: async (projectPath: string, featureId: string, filePath: string) => {
|
||||
console.log("[Mock] Getting file diff:", { projectPath, featureId, filePath });
|
||||
return {
|
||||
success: true,
|
||||
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
|
||||
filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Auto Mode state and implementation
|
||||
let mockAutoModeRunning = false;
|
||||
let mockRunningFeatures = new Set<string>(); // Track multiple concurrent feature verifications
|
||||
|
||||
@@ -108,6 +108,9 @@ export interface Feature {
|
||||
model?: AgentModel; // Model to use for this feature (defaults to opus)
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
|
||||
error?: string; // Error message if the agent errored during processing
|
||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||
worktreePath?: string; // Path to the worktree directory
|
||||
branchName?: string; // Name of the feature branch
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
|
||||
85
app/src/types/electron.d.ts
vendored
85
app/src/types/electron.d.ts
vendored
@@ -383,6 +383,91 @@ export interface ElectronAPI {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Worktree Management APIs
|
||||
worktree: WorktreeAPI;
|
||||
}
|
||||
|
||||
export interface WorktreeInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
head?: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeStatus {
|
||||
success: boolean;
|
||||
modifiedFiles?: number;
|
||||
files?: string[];
|
||||
diffStat?: string;
|
||||
recentCommits?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
}
|
||||
|
||||
export interface FileDiffsResult {
|
||||
success: boolean;
|
||||
diff?: string;
|
||||
files?: FileStatus[];
|
||||
hasChanges?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileDiffResult {
|
||||
success: boolean;
|
||||
diff?: string;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeAPI {
|
||||
// Revert feature changes by removing the worktree
|
||||
revertFeature: (projectPath: string, featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
removedPath?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Merge feature worktree changes back to main branch
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: {
|
||||
squash?: boolean;
|
||||
commitMessage?: string;
|
||||
squashMessage?: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
mergedBranch?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get worktree info for a feature
|
||||
getInfo: (projectPath: string, featureId: string) => Promise<{
|
||||
success: boolean;
|
||||
worktreePath?: string;
|
||||
branchName?: string;
|
||||
head?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get worktree status (changed files, commits)
|
||||
getStatus: (projectPath: string, featureId: string) => Promise<WorktreeStatus>;
|
||||
|
||||
// List all feature worktrees
|
||||
list: (projectPath: string) => Promise<{
|
||||
success: boolean;
|
||||
worktrees?: WorktreeInfo[];
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get file diffs for a feature worktree
|
||||
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
|
||||
|
||||
// Get diff for a specific file in a worktree
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) => Promise<FileDiffResult>;
|
||||
}
|
||||
|
||||
// Model definition type
|
||||
|
||||
Reference in New Issue
Block a user