diff --git a/.automaker/agents-context/feature-1765334243840-qmnc0ez5o.md b/.automaker/agents-context/feature-1765334243840-qmnc0ez5o.md deleted file mode 100644 index 8753d5de..00000000 --- a/.automaker/agents-context/feature-1765334243840-qmnc0ez5o.md +++ /dev/null @@ -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. (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. (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. (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. (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. (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. (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. (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. (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. (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. (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. (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. (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(' diff --git a/.automaker/feature_list.json b/.automaker/feature_list.json index e9c3df86..56c17ebf 100644 --- a/.automaker/feature_list.json +++ b/.automaker/feature_list.json @@ -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. (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. (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. (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. (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" } ] \ No newline at end of file diff --git a/.automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba b/.automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba new file mode 160000 index 00000000..a78b6763 --- /dev/null +++ b/.automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba @@ -0,0 +1 @@ +Subproject commit a78b6763de82102803fe731662483ade55dd738d diff --git a/.automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op b/.automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op new file mode 160000 index 00000000..a78b6763 --- /dev/null +++ b/.automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op @@ -0,0 +1 @@ +Subproject commit a78b6763de82102803fe731662483ade55dd738d diff --git a/app/electron/auto-mode-service.js b/app/electron/auto-mode-service.js index 6277f7c8..105ad27f 100644 --- a/app/electron/auto-mode-service.js +++ b/app/electron/auto-mode-service.js @@ -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 diff --git a/app/electron/main.js b/app/electron/main.js index 2b9826bd..974f4bf7 100644 --- a/app/electron/main.js +++ b/app/electron/main.js @@ -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 }; + } +}); diff --git a/app/electron/preload.js b/app/electron/preload.js index d9348281..d51aba4a 100644 --- a/app/electron/preload.js +++ b/app/electron/preload.js @@ -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 diff --git a/app/electron/services/feature-loader.js b/app/electron/services/feature-loader.js index 7394c34e..c5239fe3 100644 --- a/app/electron/services/feature-loader.js +++ b/app/electron/services/feature-loader.js @@ -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(); diff --git a/app/electron/services/worktree-manager.js b/app/electron/services/worktree-manager.js new file mode 100644 index 00000000..1f7fef3c --- /dev/null +++ b/app/electron/services/worktree-manager.js @@ -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(); diff --git a/app/package-lock.json b/app/package-lock.json index 93f82ab5..9a8449c1 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 38725950..a0d5faa0 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index c4ea914c..27cc5e90 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -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 ; case "context": return ; + case "profiles": + return ; default: return ; } diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index 43021508..9a8d8b99 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -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, + }, ], }, ]; diff --git a/app/src/components/ui/git-diff-panel.tsx b/app/src/components/ui/git-diff-panel.tsx new file mode 100644 index 00000000..9984a91a --- /dev/null +++ b/app/src/components/ui/git-diff-panel.tsx @@ -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 ; + case "D": + return ; + case "M": + case "U": + return ; + case "R": + case "C": + return ; + default: + return ; + } +}; + +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 ( +
+ {content} +
+ ); + } + + return ( +
+ + {lineNumber?.old ?? ""} + + + {lineNumber?.new ?? ""} + + + {prefix[type]} + + + {content || "\u00A0"} + +
+ ); +} + +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 ( +
+ + {isExpanded && ( +
+ {fileDiff.hunks.map((hunk, hunkIndex) => ( +
+ {hunk.lines.map((line, lineIndex) => ( + + ))} +
+ ))} +
+ )} +
+ ); +} + +export function GitDiffPanel({ + projectPath, + featureId, + className, + compact = true, +}: GitDiffPanelProps) { + const [isExpanded, setIsExpanded] = useState(!compact); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [diffContent, setDiffContent] = useState(""); + const [expandedFiles, setExpandedFiles] = useState>(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 ( +
+ {/* Header */} + + + {/* Content */} + {isExpanded && ( +
+ {isLoading ? ( +
+ + Loading changes... +
+ ) : error ? ( +
+ + {error} + +
+ ) : files.length === 0 ? ( +
+ No changes detected +
+ ) : ( +
+ {/* Summary bar */} +
+
+ {files.map((file) => ( +
+ {getFileIcon(file.status)} + + {file.statusText} + +
+ ))} +
+
+ + + +
+
+ + {/* Stats */} +
+ + {files.length} {files.length === 1 ? "file" : "files"} changed + + {totalAdditions > 0 && ( + + +{totalAdditions} additions + + )} + {totalDeletions > 0 && ( + + -{totalDeletions} deletions + + )} +
+ + {/* File diffs */} +
+ {parsedDiffs.map((fileDiff) => ( + toggleFile(fileDiff.filePath)} + /> + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/app/src/components/ui/tooltip.tsx b/app/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..25576f54 --- /dev/null +++ b/app/src/components/ui/tooltip.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/app/src/components/views/agent-output-modal.tsx b/app/src/components/views/agent-output-modal.tsx index ed7bef29..a643201e 100644 --- a/app/src/components/views/agent-output-modal.tsx +++ b/app/src/components/views/agent-output-modal.tsx @@ -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(""); const [isLoading, setIsLoading] = useState(true); const [viewMode, setViewMode] = useState("parsed"); + const [projectPath, setProjectPath] = useState(""); const scrollRef = useRef(null); const autoScrollRef = useRef(true); const projectPathRef = useRef(""); @@ -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" > - Parsed + Logs + + + ); + })} + +

+ Or customize below. Manage profiles in{" "} + +

+ + )} + + {/* Separator */} + {aiProfiles.length > 0 &&
} + {/* Claude Models Section */}
@@ -1816,6 +2018,79 @@ export function BoardView() { {/* Model Tab */} + {/* Quick Select Profile Section */} + {aiProfiles.length > 0 && ( +
+
+ + + Presets + +
+
+ {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 ( + + ); + })} +
+

+ Or customize below. +

+
+ )} + + {/* Separator */} + {aiProfiles.length > 0 &&
} + {/* Claude Models Section */}
diff --git a/app/src/components/views/kanban-card.tsx b/app/src/components/views/kanban-card.tsx index 3d7dfc52..714093a8 100644 --- a/app/src/components/views/kanban-card.tsx +++ b/app/src/components/views/kanban-card.tsx @@ -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(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({ Errored
)} + {/* Branch badge - show when feature has a worktree */} + {hasWorktree && !isCurrentAutoTask && ( + + + +
+ + {feature.branchName?.replace("feature/", "")} +
+
+ +

{feature.branchName}

+
+
+
+ )} {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 && ( + + + + + + +

Revert changes

+
+
+
+ )} {/* Follow-up prompt button */} {onFollowUp && ( )} - {/* Commit and verify button */} - {onCommit && ( + {/* Merge button - only show when worktree exists */} + {hasWorktree && onMerge && ( + + )} + {/* Commit and verify button - show when no worktree */} + {!hasWorktree && onCommit && ( + + + + ); } diff --git a/app/src/components/views/profiles-view.tsx b/app/src/components/views/profiles-view.tsx new file mode 100644 index 00000000..a9048a90 --- /dev/null +++ b/app/src/components/views/profiles-view.tsx @@ -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> = { + 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 ( +
+ {/* Drag Handle */} + + + {/* Icon */} +
+ {IconComponent && ( + + )} +
+ + {/* Content */} +
+
+

{profile.name}

+ {profile.isBuiltIn && ( + + + Built-in + + )} +
+

+ {profile.description} +

+
+ + {profile.model} + + {profile.thinkingLevel !== "none" && ( + + {profile.thinkingLevel} + + )} +
+
+ + {/* Actions */} + {!profile.isBuiltIn && ( +
+ + +
+ )} +
+ ); +} + +// Profile Form Component +function ProfileForm({ + profile, + onSave, + onCancel, + isEditing, +}: { + profile: Partial; + onSave: (profile: Omit) => 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 ( +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Heavy Task, Quick Fix" + data-testid="profile-name-input" + /> +
+ + {/* Description */} +
+ +