Merge branch 'feat/worktree-system' into feat/extend-models-support

This commit is contained in:
Kacper
2025-12-10 13:20:14 +01:00
22 changed files with 3260 additions and 362 deletions

View File

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

View File

@@ -72,9 +72,9 @@
"description": "When agent finish work the cards is moved either to waiting approval or into verified one But mostly its include some type of summary at the end i want you to modify our prompts and ui so when its in both states we can see the feature summary of what was done / modified instead of relying on going to code editor to see what got changed etc.",
"steps": [],
"status": "verified",
"startedAt": "2025-12-09T22:09:13.684Z",
"imagePaths": [],
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-09T22:09:13.684Z",
"model": "opus",
"thinkingLevel": "none"
},
@@ -84,9 +84,9 @@
"description": "When running new feature in skip automated testing once its got finished its moved to waiting approval for us to manual test it / follow up prompt. Once we are satisfied we can click commit button so ai agent can commit it work this is only hapening in this scenerio because if we have unchecked the skip automated testing its do it automaticly and commit already. But the issue is when its going to commit we move it to in progress state where we can use stop button and if user use that button its moved to backlog column and. that kinda break what we are doing becase we have no longer even abbility to move it back to waiting approval or to run commit button / follow up again so if user use manual one and stop the commit i want it to be again moved back to waiting approval state / column",
"steps": [],
"status": "verified",
"startedAt": "2025-12-09T22:31:41.946Z",
"imagePaths": [],
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-09T22:31:41.946Z",
"model": "opus",
"thinkingLevel": "none"
},
@@ -102,9 +102,9 @@
"agent execute task with correct model "
],
"status": "verified",
"startedAt": "2025-12-09T23:07:37.223Z",
"imagePaths": [],
"skipTests": false,
"imagePaths": [],
"startedAt": "2025-12-09T23:07:37.223Z",
"summary": "Added model selection (Haiku/Sonnet/Opus) and thinking level (None/Low/Medium/High) controls to feature creation and edit dialogs. Modified: app-store.ts (added AgentModel and ThinkingLevel types), board-view.tsx (UI controls), feature-executor.js (dynamic model/thinking config), feature-loader.js (field persistence). Agent now executes with user-selected model and extended thinking settings.",
"model": "opus",
"thinkingLevel": "none"
@@ -115,9 +115,9 @@
"description": "I want you to refactor the add new feature modal there are to many settings going on and its hard / annoyig to navigate lets split the settings in modal into tabs \nprompt icon - prompt and category\ngear icon - model and thinking ( here i would also like to split somehow the claude with thinking and codex that dont use it )\ntest icon - skip automated testing and verification steps\n",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T02:17:18.943Z",
"imagePaths": [],
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T02:17:18.943Z",
"summary": "Made model selection buttons compact. Removed descriptions and badges from cards, now shows short model names (Haiku, Sonnet, Opus, Max, Codex, Mini) in horizontal row. Full description available on hover. Modified: board-view.tsx (renderModelOptions function).",
"model": "opus",
"thinkingLevel": "high"
@@ -128,7 +128,7 @@
"description": "Make the add new feature modal widther ",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T02:25:21.328Z",
"skipTests": true,
"imagePaths": [
{
"id": "img-1765333063064-qygrbjul4",
@@ -137,33 +137,20 @@
"mimeType": "image/png"
}
],
"skipTests": true,
"startedAt": "2025-12-10T02:25:21.328Z",
"summary": "Increased dialog max-width from max-w-md/max-w-lg to max-w-2xl. Modified: app/src/components/ui/dialog.tsx. This makes the add new feature modal and all other dialogs wider (from 448-512px to 672px) for better content display.",
"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",
"description": "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",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T02:40:43.706Z",
"imagePaths": [],
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T02:40:43.706Z",
"summary": "Kanban cards now render the agent info model badge using feature.model so the displayed model matches the one selected for the task.",
"model": "gpt-5.1-codex",
"thinkingLevel": "none"
@@ -174,7 +161,7 @@
"description": "describe the attached image do not change code",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T02:02:54.785Z",
"skipTests": true,
"imagePaths": [
{
"id": "img-1765331797511-v4ssc1hha",
@@ -183,7 +170,7 @@
"mimeType": "image/png"
}
],
"skipTests": true,
"startedAt": "2025-12-10T02:02:54.785Z",
"model": "opus",
"thinkingLevel": "none"
},
@@ -193,7 +180,7 @@
"description": "Add claude and codex to the left sidebar of settings so its will scroll to thoes sections as well",
"steps": [],
"status": "verified",
"startedAt": "2025-12-10T09:32:31.638Z",
"skipTests": true,
"imagePaths": [
{
"id": "img-1765358823366-6vchdhwsj",
@@ -202,40 +189,91 @@
"mimeType": "image/png"
}
],
"skipTests": true,
"startedAt": "2025-12-10T09:32:31.638Z",
"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",
"startedAt": "2025-12-10T09:59:02.988Z",
"imagePaths": [],
"status": "verified",
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T09:59:02.988Z",
"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",
"skipTests": true,
"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"
}
],
"startedAt": "2025-12-10T10:46:42.494Z",
"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": "verified",
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T11:11:06.115Z",
"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": "verified",
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T11:16:54.069Z",
"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"
},
{
"id": "feature-1765366278888-fobz39cc4",
"category": "Core",
"description": "Implement profile view and in the sidebar the profile view would allow user to defined different ai provider profiels like heavy-task would be claude opus model with ultrathink or debugging would be codex max. This will give user flexibillity in our model tab to quickly use own defined profiles preset of models.",
"steps": [],
"status": "waiting_approval",
"skipTests": true,
"imagePaths": [],
"startedAt": "2025-12-10T11:31:20.842Z",
"summary": "Implemented AI Profiles feature for managing model configuration presets. Created: profiles-view.tsx. Modified: app-store.ts (added AIProfile type, state, and CRUD actions), sidebar.tsx (added profiles nav item), page.tsx (added profiles view routing), board-view.tsx (added Quick Select Profile section in Add/Edit Feature dialogs). Features: 5 built-in profiles (Heavy Task, Balanced, Quick Edit, Codex Power, Codex Fast), custom profile CRUD, drag-and-drop reordering, quick profile selection in feature dialogs.",
"model": "opus",
"thinkingLevel": "high"
}
]

Submodule .automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba added at a78b6763de

Submodule .automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op added at a78b6763de

View File

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

View File

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

View File

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

View File

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

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

76
app/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3186,6 +3187,58 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -3322,6 +3375,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -72,7 +73,9 @@
{
"from": ".env",
"to": ".env",
"filter": ["**/*"]
"filter": [
"**/*"
]
}
],
"mac": {
@@ -80,11 +83,17 @@
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
}
],
"icon": "public/logo.png"
@@ -93,7 +102,9 @@
"target": [
{
"target": "nsis",
"arch": ["x64"]
"arch": [
"x64"
]
}
],
"icon": "public/logo.png"
@@ -102,11 +113,15 @@
"target": [
{
"target": "AppImage",
"arch": ["x64"]
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": ["x64"]
"arch": [
"x64"
]
}
],
"category": "Development",

View File

@@ -10,6 +10,7 @@ import { SettingsView } from "@/components/views/settings-view";
import { AgentToolsView } from "@/components/views/agent-tools-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
@@ -109,6 +110,8 @@ export default function Home() {
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
default:
return <WelcomeView />;
}

View File

@@ -21,6 +21,7 @@ import {
GripVertical,
Trash2,
Undo2,
UserCircle,
} from "lucide-react";
import {
DropdownMenu,
@@ -357,6 +358,11 @@ export function Sidebar() {
icon: Wrench,
shortcut: NAV_SHORTCUTS.tools,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
},
],
},
];

View 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>
);
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

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

View File

@@ -23,6 +23,7 @@ import {
FeatureImagePath,
AgentModel,
ThinkingLevel,
AIProfile,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
@@ -71,6 +72,11 @@ import {
Brain,
Zap,
Settings2,
Scale,
Cpu,
Rocket,
Sparkles,
UserCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -155,6 +161,16 @@ const CODEX_MODELS: ModelOption[] = [
},
];
// Profile icon mapping
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
export function BoardView() {
const {
currentProject,
@@ -168,6 +184,7 @@ export function BoardView() {
maxConcurrency,
setMaxConcurrency,
defaultSkipTests,
aiProfiles,
} = useAppStore();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
@@ -1090,6 +1107,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 +1580,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
@@ -1573,6 +1692,89 @@ export function BoardView() {
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<UserCircle className="w-4 h-4 text-brand-500" />
Quick Select Profile
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
Presets
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{aiProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
const isSelected = newFeature.model === profile.model &&
newFeature.thinkingLevel === profile.thinkingLevel;
return (
<button
key={profile.id}
type="button"
onClick={() => {
setNewFeature({
...newFeature,
model: profile.model,
thinkingLevel: profile.thinkingLevel,
});
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
isSelected
? "bg-brand-500/10 border-brand-500 text-foreground"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`profile-quick-select-${profile.id}`}
>
<div className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}>
{IconComponent && (
<IconComponent className={cn(
"w-4 h-4",
isCodex ? "text-emerald-500" : "text-primary"
)} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}{profile.thinkingLevel !== "none" && ` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Or customize below. Manage profiles in{" "}
<button
type="button"
onClick={() => {
setShowAddDialog(false);
useAppStore.getState().setCurrentView("profiles");
}}
className="text-brand-500 hover:underline"
>
AI Profiles
</button>
</p>
</div>
)}
{/* Separator */}
{aiProfiles.length > 0 && <div className="border-t border-border" />}
{/* Claude Models Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -1816,6 +2018,79 @@ export function BoardView() {
{/* Model Tab */}
<TabsContent value="model" className="space-y-4 overflow-y-auto">
{/* Quick Select Profile Section */}
{aiProfiles.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<UserCircle className="w-4 h-4 text-brand-500" />
Quick Select Profile
</Label>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-brand-500/40 text-brand-500">
Presets
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{aiProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
const isSelected = editingFeature.model === profile.model &&
editingFeature.thinkingLevel === profile.thinkingLevel;
return (
<button
key={profile.id}
type="button"
onClick={() => {
setEditingFeature({
...editingFeature,
model: profile.model,
thinkingLevel: profile.thinkingLevel,
});
if (profile.thinkingLevel === "ultrathink") {
toast.warning("Ultrathink Selected", {
description: "Ultrathink uses extensive reasoning (45-180s, ~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border text-left transition-all",
isSelected
? "bg-brand-500/10 border-brand-500 text-foreground"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`edit-profile-quick-select-${profile.id}`}
>
<div className={cn(
"w-7 h-7 rounded flex items-center justify-center flex-shrink-0",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}>
{IconComponent && (
<IconComponent className={cn(
"w-4 h-4",
isCodex ? "text-emerald-500" : "text-primary"
)} />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}{profile.thinkingLevel !== "none" && ` + ${profile.thinkingLevel}`}
</p>
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Or customize below.
</p>
</div>
)}
{/* Separator */}
{aiProfiles.length > 0 && <div className="border-t border-border" />}
{/* Claude Models Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -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";
@@ -59,6 +62,12 @@ import {
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { Markdown } from "@/components/ui/markdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface KanbanCardProps {
feature: Feature;
@@ -72,6 +81,8 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -93,6 +104,8 @@ export function KanbanCard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -101,9 +114,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 +263,41 @@ export function KanbanCard({
<span>Errored</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"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}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<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,24 +662,65 @@ export function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Revert button - only show when worktree exists (icon only to save space) */}
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20 shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Follow-up prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1" />
Follow-up
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
</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 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{/* Commit and verify button - show when no worktree */}
{!hasWorktree && onCommit && (
<Button
variant="default"
size="sm"
@@ -736,6 +824,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>
);
}

View File

@@ -0,0 +1,655 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
UserCircle,
Plus,
Pencil,
Trash2,
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
GripVertical,
Lock,
Check,
} from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// Icon mapping for profiles
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1", label: "GPT-5.1" },
];
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];
// Helper to determine provider from model
function getProviderFromModel(model: AgentModel): ModelProvider {
if (model.startsWith("gpt")) {
return "codex";
}
return "claude";
}
// Sortable Profile Card Component
function SortableProfileCard({
profile,
onEdit,
onDelete,
}: {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && (
<IconComponent
className={cn(
"w-5 h-5",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border",
isCodex
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "border-primary/30 text-primary bg-primary/10"
)}
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
// Profile Form Component
function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
}: {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
}) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
const newProvider = getProviderFromModel(model);
setFormData({
...formData,
model,
// Reset thinking level when switching to Codex (doesn't support thinking)
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection - Claude */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude Models
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Model Selection - Codex */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
Codex Models
</Label>
<div className="flex gap-2 flex-wrap">
{CODEX_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-emerald-600 text-white border-emerald-500"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level - Only for Claude models */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
{/* Actions */}
<DialogFooter className="pt-4">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSubmit} data-testid="save-profile-button">
{isEditing ? "Save Changes" : "Create Profile"}
</Button>
</DialogFooter>
</div>
);
}
export function ProfilesView() {
const { aiProfiles, addAIProfile, updateAIProfile, removeAIProfile, reorderAIProfiles } =
useAppStore();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(
() => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
description: `Updated "${profile.name}" profile`,
});
}
};
const handleDeleteProfile = (profile: AIProfile) => {
if (profile.isBuiltIn) return;
removeAIProfile(profile.id);
toast.success("Profile deleted", {
description: `Deleted "${profile.name}" profile`,
});
};
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
{/* Header Section */}
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<Button onClick={() => setShowAddDialog(true)} data-testid="add-profile-button">
<Plus className="w-4 h-4 mr-2" />
New Profile
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">
No custom profiles yet. Create one to get started!
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setShowAddDialog(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be
edited or deleted.
</p>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog">
<DialogHeader>
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

@@ -10,7 +10,8 @@ export type ViewMode =
| "settings"
| "tools"
| "interview"
| "context";
| "context"
| "profiles";
export type ThemeMode =
| "light"
@@ -94,6 +95,18 @@ export type ModelProvider = "claude" | "codex";
// Thinking level (budget_tokens) options
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
// AI Provider Profile - user-defined presets for model configurations
export interface AIProfile {
id: string;
name: string;
description: string;
model: AgentModel;
thinkingLevel: ThinkingLevel;
provider: ModelProvider;
isBuiltIn: boolean; // Built-in profiles cannot be deleted
icon?: string; // Optional icon name from lucide
}
export interface Feature {
id: string;
category: string;
@@ -108,6 +121,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 {
@@ -151,6 +167,9 @@ export interface AppState {
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
// AI Profiles
aiProfiles: AIProfile[];
}
export interface AutoModeActivity {
@@ -236,10 +255,70 @@ export interface AppActions {
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
removeAIProfile: (id: string) => void;
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
// Reset
reset: () => void;
}
// Default built-in AI profiles
const DEFAULT_AI_PROFILES: AIProfile[] = [
{
id: "profile-heavy-task",
name: "Heavy Task",
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
model: "opus",
thinkingLevel: "ultrathink",
provider: "claude",
isBuiltIn: true,
icon: "Brain",
},
{
id: "profile-balanced",
name: "Balanced",
description: "Claude Sonnet with medium thinking for typical development tasks.",
model: "sonnet",
thinkingLevel: "medium",
provider: "claude",
isBuiltIn: true,
icon: "Scale",
},
{
id: "profile-quick-edit",
name: "Quick Edit",
description: "Claude Haiku for fast, simple edits and minor fixes.",
model: "haiku",
thinkingLevel: "none",
provider: "claude",
isBuiltIn: true,
icon: "Zap",
},
{
id: "profile-codex-power",
name: "Codex Power",
description: "GPT-5.1 Codex Max for deep coding tasks via OpenAI CLI.",
model: "gpt-5.1-codex-max",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Cpu",
},
{
id: "profile-codex-fast",
name: "Codex Fast",
description: "GPT-5.1 Codex Mini for lightweight and quick edits.",
model: "gpt-5.1-codex-mini",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Rocket",
},
];
const initialState: AppState = {
projects: [],
currentProject: null,
@@ -264,6 +343,7 @@ const initialState: AppState = {
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: "standard", // Default to standard detail level
defaultSkipTests: false, // Default to TDD mode (tests enabled)
aiProfiles: DEFAULT_AI_PROFILES,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -592,6 +672,37 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] });
},
updateAIProfile: (id, updates) => {
set({
aiProfiles: get().aiProfiles.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
});
},
removeAIProfile: (id) => {
// Only allow removing non-built-in profiles
const profile = get().aiProfiles.find((p) => p.id === id);
if (profile && !profile.isBuiltIn) {
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
}
},
reorderAIProfiles: (oldIndex, newIndex) => {
const profiles = [...get().aiProfiles];
const [movedProfile] = profiles.splice(oldIndex, 1);
profiles.splice(newIndex, 0, movedProfile);
set({ aiProfiles: profiles });
},
// Reset
reset: () => set(initialState),
}),
@@ -610,6 +721,7 @@ export const useAppStore = create<AppState & AppActions>()(
maxConcurrency: state.maxConcurrency,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
aiProfiles: state.aiProfiles,
}),
}
)

View File

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