Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0acdba6f01 | |||
| de2eccd209 | |||
| 948db57763 | |||
| f98a13eaa0 | |||
| 24ff491d3c | |||
| a8f2d0b110 | |||
| 46d2536600 | |||
| 71535581c2 | |||
| 26725286d5 | |||
| 569e921759 | |||
| 03ccce5cc1 | |||
| 64d5170c94 | |||
| 0710c13964 | |||
| 56cedec2ae | |||
| c0c8067bc5 | |||
| db3a034d75 | |||
| 059315d6ab | |||
| 8df7ba4f16 | |||
| 2bffea842b | |||
| 99cf21e61b | |||
| da5e26b923 | |||
| c957eaa3a1 | |||
| 73d01c0103 | |||
| 41a507fe8b | |||
| e02aa597f2 | |||
| 909305c82b | |||
| 121b2b294f | |||
| c2fe3322a7 | |||
| aac6b106aa | |||
| 7f6beba3ad | |||
| 4b354e7b9f | |||
| eed5297e9d | |||
| 9a5ca8c78f | |||
| 63a1d3c138 | |||
| 3caf9cf18e | |||
| 7d351e3422 | |||
| 9dea155505 | |||
| d3cdd3a1c7 | |||
| a9d1ddb84f | |||
| 6985934825 | |||
| 4f1766b501 | |||
| 7ff326d898 | |||
| 48f7c3cc61 | |||
| 892e01d608 | |||
| 54501cbd73 | |||
| f1d578fd18 | |||
| 2e3a5d9de5 | |||
| a6dad428e9 | |||
| 3d24f8f59e | |||
| 9106038a17 | |||
| 895ed9f605 | |||
| cbe14fda9b | |||
| 4c1dd89840 | |||
| d93eefe806 | |||
| e11f5fcd50 | |||
| 8e891dfcfe | |||
| c721dc23b6 | |||
| 1a2b7a1bbb | |||
| b194c0e11f | |||
| 6cff4420c9 | |||
| 12bf69def6 | |||
| 3818b4641f | |||
| 219b66dcc6 | |||
| 4e63e8559d |
@@ -0,0 +1,132 @@
|
||||
name: Build Native Module Prebuilds
|
||||
|
||||
on:
|
||||
# Build on releases
|
||||
release:
|
||||
types: [published]
|
||||
# Manual trigger for testing
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
electron_version:
|
||||
description: 'Electron version to build for'
|
||||
required: false
|
||||
default: '39.2.6'
|
||||
|
||||
env:
|
||||
# Default Electron version - update when upgrading Electron in package.json
|
||||
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '39.2.6' }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64]
|
||||
# Add arm64 when GitHub Actions supports Windows ARM runners
|
||||
# arch: [x64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Visual Studio Build Tools
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Install node-pty and rebuild for Electron
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install only node-pty
|
||||
pnpm add node-pty@1.1.0-beta42
|
||||
|
||||
# Get Electron ABI version
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
Write-Host "Building for Electron $env:ELECTRON_VERSION (ABI: $electronAbi)"
|
||||
|
||||
# Rebuild node-pty for Electron
|
||||
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Package prebuilt binaries
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$prebuildDir = "prebuilds/win32-${{ matrix.arch }}-electron-$electronAbi"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $prebuildDir
|
||||
|
||||
# Copy all built native files
|
||||
$buildDir = "node_modules/node-pty/build/Release"
|
||||
if (Test-Path $buildDir) {
|
||||
Copy-Item "$buildDir/*.node" $prebuildDir/ -Force
|
||||
Copy-Item "$buildDir/*.dll" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item "$buildDir/*.exe" $prebuildDir/ -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Also copy conpty files if they exist in subdirectory
|
||||
if (Test-Path "$buildDir/conpty") {
|
||||
Copy-Item "$buildDir/conpty/*" $prebuildDir/ -Force
|
||||
}
|
||||
}
|
||||
|
||||
# List what we packaged
|
||||
Write-Host "Packaged prebuilds:"
|
||||
Get-ChildItem $prebuildDir
|
||||
|
||||
- name: Create archive
|
||||
working-directory: auto-claude-ui
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
$archiveName = "node-pty-win32-${{ matrix.arch }}-electron-$electronAbi.zip"
|
||||
|
||||
Compress-Archive -Path "prebuilds/*" -DestinationPath $archiveName
|
||||
|
||||
Write-Host "Created archive: $archiveName"
|
||||
Get-ChildItem $archiveName
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-win32-${{ matrix.arch }}
|
||||
path: auto-claude-ui/node-pty-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: auto-claude-ui/node-pty-*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Create a combined prebuilds package
|
||||
package-prebuilds:
|
||||
needs: build-windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: List artifacts
|
||||
run: |
|
||||
echo "Downloaded artifacts:"
|
||||
find artifacts -type f -name "*.zip"
|
||||
|
||||
- name: Upload combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-prebuilds-all
|
||||
path: artifacts/**/*.zip
|
||||
retention-days: 90
|
||||
@@ -74,6 +74,7 @@ dmypy.json
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.claude_settings.json
|
||||
.update-metadata.json
|
||||
|
||||
# Development of Auto Build with Auto Build
|
||||
dev/
|
||||
|
||||
+174
@@ -1,3 +1,177 @@
|
||||
## 2.5.0 - Roadmap Intelligence & Workflow Refinements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Interactive competitor analysis viewer for roadmap planning with real-time data visualization
|
||||
|
||||
- GitHub issue label mapping to task categories for improved organization and tracking
|
||||
|
||||
- GitHub issue comment selection in task creation workflow for better context integration
|
||||
|
||||
- TaskCreationWizard enhanced with drag-and-drop support for file references and inline @mentions
|
||||
|
||||
- Roadmap generation now includes stop functionality and comprehensive debug logging
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Refined visual drop zone feedback in file reference system for more subtle user guidance
|
||||
|
||||
- Remove auto-expand behavior for referenced files on draft restore to improve UX
|
||||
|
||||
- Always-visible referenced files section in TaskCreationWizard for better discoverability
|
||||
|
||||
- Drop zone wrapper added around main modal content area for improved drag-and-drop ergonomics
|
||||
|
||||
- Stuck task detection now enabled for ai_review status to better track blocked work
|
||||
|
||||
- Enhanced React component stability with proper key usage in RoadmapHeader and PhaseProgressIndicator
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Corrected CompetitorAnalysisViewer type definitions for proper TypeScript compliance
|
||||
|
||||
- Fixed multiple CodeRabbit review feedback items for improved code quality
|
||||
|
||||
- Resolved React key warnings in PhaseProgressIndicator component
|
||||
|
||||
- Fixed git status parsing in merge preview for accurate worktree state detection
|
||||
|
||||
- Corrected path resolution in runners for proper module imports and .env loading
|
||||
|
||||
- Resolved CI lint and TypeScript errors across codebase
|
||||
|
||||
- Fixed HTTP error handling and path resolution issues in core modules
|
||||
|
||||
- Corrected worktree test to match intended branch detection behavior
|
||||
|
||||
- Refined TaskReview component conditional rendering for proper staged task display
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat: add interactive competitor analysis viewer for roadmap by @AndyMik90 in 7ff326d
|
||||
|
||||
- fix: correct CompetitorAnalysisViewer to match type definitions by @AndyMik90 in 4f1766b
|
||||
|
||||
- fix: address multiple CodeRabbit review feedback items by @AndyMik90 in 48f7c3c
|
||||
|
||||
- fix: use stable React keys instead of array indices in RoadmapHeader by @AndyMik90 in 892e01d
|
||||
|
||||
- fix: additional fixes for http error handling and path resolution by @AndyMik90 in 54501cb
|
||||
|
||||
- fix: update worktree test to match intended branch detection behavior by @AndyMik90 in f1d578f
|
||||
|
||||
- fix: resolve CI lint and TypeScript errors by @AndyMik90 in 2e3a5d9
|
||||
|
||||
- feat: enhance roadmap generation with stop functionality and debug logging by @AndyMik90 in a6dad42
|
||||
|
||||
- fix: correct path resolution in runners for module imports and .env loading by @AndyMik90 in 3d24f8f
|
||||
|
||||
- fix: resolve React key warning in PhaseProgressIndicator by @AndyMik90 in 9106038
|
||||
|
||||
- fix: enable stuck task detection for ai_review status by @AndyMik90 in 895ed9f
|
||||
|
||||
- feat: map GitHub issue labels to task categories by @AndyMik90 in cbe14fd
|
||||
|
||||
- feat: add GitHub issue comment selection and fix auto-start bug by @AndyMik90 in 4c1dd89
|
||||
|
||||
- feat: enhance TaskCreationWizard with drag-and-drop support for file references and inline @mentions by @AndyMik90 in d93eefe
|
||||
|
||||
- cleanup docs by @AndyMik90 in 8e891df
|
||||
|
||||
- fix: correct git status parsing in merge preview by @AndyMik90 in c721dc2
|
||||
|
||||
- Update TaskReview component to refine conditional rendering for staged tasks, ensuring proper display when staging is unsuccessful by @AndyMik90 in 1a2b7a1
|
||||
|
||||
- auto-claude: subtask-2-3 - Refine visual drop zone feedback to be more subtle by @AndyMik90 in 6cff442
|
||||
|
||||
- auto-claude: subtask-2-1 - Remove showFiles auto-expand on draft restore by @AndyMik90 in 12bf69d
|
||||
|
||||
- auto-claude: subtask-1-3 - Create an always-visible referenced files section by @AndyMik90 in 3818b46
|
||||
|
||||
- auto-claude: subtask-1-2 - Add drop zone wrapper around main modal content area by @AndyMik90 in 219b66d
|
||||
|
||||
- auto-claude: subtask-1-1 - Remove Reference Files toggle button by @AndyMik90 in 4e63e85
|
||||
|
||||
## 2.4.0 - Enhanced Cross-Platform Experience with OAuth & Auto-Updates
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Claude account OAuth implementation on onboarding for seamless token setup
|
||||
|
||||
- Integrated release workflow with AI-powered version suggestion capabilities
|
||||
|
||||
- Auto-upgrading functionality supporting Windows, Linux, and macOS with automatic app updates
|
||||
|
||||
- Git repository initialization on app startup with project addition checks
|
||||
|
||||
- Debug logging for app updater to track update processes
|
||||
|
||||
- Auto-open settings to updates section when app update is ready
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Major Windows and Linux compatibility enhancements for cross-platform reliability
|
||||
|
||||
- Enhanced task status handling to support 'done' status in limbo state with worktree existence checks
|
||||
|
||||
- Better handling of lock files from worktrees upon merging
|
||||
|
||||
- Improved README documentation and build process
|
||||
|
||||
- Refined visual drop zone feedback for more subtle user experience
|
||||
|
||||
- Removed showFiles auto-expand on draft restore for better UX consistency
|
||||
|
||||
- Created always-visible referenced files section in task creation wizard
|
||||
|
||||
- Removed Reference Files toggle button for streamlined interface
|
||||
|
||||
- Worktree manual deletion enforcement for early access safety (prevents accidental work loss)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Corrected git status parsing in merge preview functionality
|
||||
|
||||
- Fixed ESLint warnings and failing tests
|
||||
|
||||
- Fixed Windows/Linux Python handling for cross-platform compatibility
|
||||
|
||||
- Fixed Windows/Linux source path detection
|
||||
|
||||
- Refined TaskReview component conditional rendering for proper staged task display
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- docs: cleanup docs by @AndyMik90 in 8e891df
|
||||
- fix: correct git status parsing in merge preview by @AndyMik90 in c721dc2
|
||||
- refactor: Update TaskReview component to refine conditional rendering for staged tasks by @AndyMik90 in 1a2b7a1
|
||||
- feat: Enhance task status handling to allow 'done' status in limbo state by @AndyMik90 in a20b8cf
|
||||
- improvement: Worktree needs to be manually deleted for early access safety by @AndyMik90 in 0ed6afb
|
||||
- feat: Claude account OAuth implementation on onboarding by @AndyMik90 in 914a09d
|
||||
- fix: Better handling of lock files from worktrees upon merging by @AndyMik90 in e44202a
|
||||
- feat: GitHub OAuth integration upon onboarding by @AndyMik90 in 4249644
|
||||
- chore: lock update by @AndyMik90 in b0fc497
|
||||
- improvement: Improved README and build process by @AndyMik90 in 462edcd
|
||||
- fix: ESLint warnings and failing tests by @AndyMik90 in affbc48
|
||||
- feat: Major Windows and Linux compatibility enhancements with auto-upgrade by @AndyMik90 in d7fd1a2
|
||||
- feat: Add debug logging to app updater by @AndyMik90 in 96dd04d
|
||||
- feat: Auto-open settings to updates section when app update is ready by @AndyMik90 in 1d0566f
|
||||
- feat: Add integrated release workflow with AI version suggestion by @AndyMik90 in 7f3cd59
|
||||
- fix: Windows/Linux Python handling by @AndyMik90 in 0ef0e15
|
||||
- feat: Implement Electron app auto-updater by @AndyMik90 in efc112a
|
||||
- fix: Windows/Linux source path detection by @AndyMik90 in d33a0aa
|
||||
- refactor: Refine visual drop zone feedback to be more subtle by @AndyMik90 in 6cff442
|
||||
- refactor: Remove showFiles auto-expand on draft restore by @AndyMik90 in 12bf69d
|
||||
- feat: Create always-visible referenced files section by @AndyMik90 in 3818b46
|
||||
- feat: Add drop zone wrapper around main modal content by @AndyMik90 in 219b66d
|
||||
- feat: Remove Reference Files toggle button by @AndyMik90 in 4e63e85
|
||||
- docs: Update README with git initialization and folder structure by @AndyMik90 in 2fa3c51
|
||||
- chore: Version bump to 2.3.2 by @AndyMik90 in 59b091a
|
||||
|
||||
## 2.3.2 - UI Polish & Build Improvements
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
@@ -4,7 +4,7 @@ Your AI coding companion. Build features, fix bugs, and ship faster — with aut
|
||||
|
||||

|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## What It Does ✨
|
||||
|
||||
@@ -35,7 +35,7 @@ The Desktop UI is the recommended way to use Auto Claude. It provides visual tas
|
||||
### Prerequisites
|
||||
|
||||
1. **Node.js 18+** - [Download Node.js](https://nodejs.org/)
|
||||
2. **Python 3.9+** - [Download Python](https://www.python.org/downloads/)
|
||||
2. **Python 3.10+** - [Download Python](https://www.python.org/downloads/)
|
||||
3. **Docker Desktop** - Required for the Memory Layer
|
||||
4. **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
5. **Claude Subscription** - Requires [Claude Pro or Max](https://claude.ai/upgrade) for Claude Code access
|
||||
@@ -114,6 +114,18 @@ pnpm run build && pnpm run start
|
||||
# or: npm run build && npm run start
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Windows users:</b> If installation fails with node-gyp errors, click here</summary>
|
||||
|
||||
Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts aren't available for your Electron version yet, you'll need Visual Studio Build Tools:
|
||||
|
||||
1. Download [Visual Studio Build Tools 2022](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
|
||||
2. Select "Desktop development with C++" workload
|
||||
3. In "Individual Components", add "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
4. Restart terminal and run `npm install` again
|
||||
|
||||
</details>
|
||||
|
||||
### Step 4: Start Building
|
||||
|
||||
1. Add your project in the UI
|
||||
@@ -309,7 +321,7 @@ See `auto-claude/.env.example` for complete configuration options.
|
||||
|
||||
Join our Discord to get help, share what you're building, and connect with other Auto Claude users:
|
||||
|
||||
[](https://discord.gg/maj9EWmY)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# Auto Claude Update System Analysis
|
||||
|
||||
## Current State
|
||||
|
||||
The app has **TWO separate update systems** for different components:
|
||||
|
||||
### 1. ✅ Auto Claude Framework Updates (WORKING)
|
||||
|
||||
**What it updates:** The Python framework source code (`auto-claude/` directory)
|
||||
|
||||
**How it works:**
|
||||
- Checks GitHub Releases API for new versions
|
||||
- Downloads release tarball
|
||||
- Extracts and applies update to the bundled source
|
||||
- Preserves user configuration files (.env, etc.)
|
||||
|
||||
**User Experience:**
|
||||
- **Settings > Advanced > Updates** section
|
||||
- Visual update checker with version display
|
||||
- Release notes rendered in UI
|
||||
- Progress bar during download
|
||||
- One-click update button
|
||||
- Works across all platforms (macOS, Windows, Linux)
|
||||
|
||||
**Files:**
|
||||
- `auto-claude-ui/src/main/auto-claude-updater.ts` - Main updater module
|
||||
- `auto-claude-ui/src/main/updater/update-checker.ts` - Update checking
|
||||
- `auto-claude-ui/src/main/updater/update-installer.ts` - Download & install
|
||||
- `auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts` - IPC handlers
|
||||
- `auto-claude-ui/src/renderer/components/settings/AdvancedSettings.tsx` - UI
|
||||
|
||||
**Status:** ✅ **FULLY FUNCTIONAL** - Non-technical users can update the framework with one click!
|
||||
|
||||
---
|
||||
|
||||
### 2. ❌ Electron App Updates (NOT IMPLEMENTED)
|
||||
|
||||
**What it updates:** The Electron application itself (Auto Claude UI)
|
||||
|
||||
**Current State:**
|
||||
- ❌ No `electron-updater` dependency installed
|
||||
- ❌ No auto-update configuration in electron-builder
|
||||
- ❌ No update checking in main process
|
||||
- ❌ No UI for app update notifications
|
||||
- ❌ Users must manually download new releases from GitHub
|
||||
|
||||
**What users currently need to do:**
|
||||
1. Go to GitHub Releases page
|
||||
2. Download the appropriate installer (.dmg, .exe, .AppImage, etc.)
|
||||
3. Run the installer
|
||||
4. Manually replace the old app
|
||||
|
||||
---
|
||||
|
||||
## What Needs to Be Implemented
|
||||
|
||||
To enable automatic Electron app updates for non-technical users, we need to add:
|
||||
|
||||
### 1. Install electron-updater
|
||||
|
||||
```bash
|
||||
npm install electron-updater
|
||||
```
|
||||
|
||||
### 2. Configure electron-builder for Publishing
|
||||
|
||||
Add to `package.json` build config:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "AndyMik90",
|
||||
"repo": "Auto-Claude"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Implement Auto-Update Logic in Main Process
|
||||
|
||||
Create `auto-claude-ui/src/main/app-updater.ts`:
|
||||
|
||||
```typescript
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
export function initializeAppUpdater(mainWindow: BrowserWindow) {
|
||||
// Configure update checking
|
||||
autoUpdater.autoDownload = false; // Let user decide
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Check for updates on launch (after 3 seconds)
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates();
|
||||
}, 3000);
|
||||
|
||||
// Check periodically (every 4 hours)
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates();
|
||||
}, 4 * 60 * 60 * 1000);
|
||||
|
||||
// Event handlers
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
mainWindow.webContents.send('app-update-available', {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseDate: info.releaseDate
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
mainWindow.webContents.send('app-update-downloaded', {
|
||||
version: info.version
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
console.error('App update error:', error);
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send('app-update-progress', {
|
||||
percent: progress.percent,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// IPC handlers
|
||||
export function registerAppUpdateHandlers() {
|
||||
ipcMain.handle('app-update-download', async () => {
|
||||
await autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update-install', () => {
|
||||
autoUpdater.quitAndInstall();
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update-check', async () => {
|
||||
return await autoUpdater.checkForUpdates();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add UI Notification Component
|
||||
|
||||
Create an update banner or modal in the renderer that:
|
||||
- Shows when app update is available
|
||||
- Displays version and release notes
|
||||
- Has "Download Update" button
|
||||
- Shows download progress
|
||||
- Has "Install and Restart" button after download
|
||||
|
||||
### 5. Update GitHub Release Workflow
|
||||
|
||||
Ensure GitHub releases are created with proper assets:
|
||||
- macOS: `.dmg` and `.zip` files + `latest-mac.yml`
|
||||
- Windows: `.exe` installer + `latest.yml`
|
||||
- Linux: `.AppImage` and `.deb` + `latest-linux.yml`
|
||||
|
||||
The `latest-*.yml` files are auto-generated by electron-builder and contain update metadata.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Option A: Full Auto-Update (Recommended)
|
||||
|
||||
**Pros:**
|
||||
- Best user experience
|
||||
- Automatic background downloads
|
||||
- One-click install
|
||||
- Industry standard
|
||||
|
||||
**Cons:**
|
||||
- Requires code signing certificates for production (macOS, Windows)
|
||||
- Without signing, users get security warnings
|
||||
|
||||
### Option B: Update Notification Only
|
||||
|
||||
**Pros:**
|
||||
- Simpler implementation
|
||||
- No code signing required
|
||||
- User downloads from GitHub (trusted source)
|
||||
|
||||
**Cons:**
|
||||
- Users still need to manually download and install
|
||||
- Less convenient than auto-update
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Just notify, don't auto-download
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
// Show notification with link to GitHub Releases
|
||||
mainWindow.webContents.send('app-update-available', {
|
||||
version: info.version,
|
||||
downloadUrl: `https://github.com/AndyMik90/Auto-Claude/releases/tag/v${info.version}`
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development vs Production
|
||||
|
||||
**Important:** `electron-updater` only works in **packaged apps**, not in development mode.
|
||||
|
||||
During development:
|
||||
```typescript
|
||||
if (app.isPackaged) {
|
||||
initializeAppUpdater(mainWindow);
|
||||
} else {
|
||||
console.log('[Dev] Auto-updater disabled in development mode');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Signing Requirements
|
||||
|
||||
For production auto-updates without security warnings:
|
||||
|
||||
### macOS
|
||||
- Requires Apple Developer account ($99/year)
|
||||
- Code signing certificate
|
||||
- Notarization with Apple
|
||||
|
||||
### Windows
|
||||
- Requires code signing certificate (~$200-400/year)
|
||||
- Without: Windows SmartScreen warnings
|
||||
|
||||
### Linux
|
||||
- No code signing required
|
||||
- Users may need to mark `.AppImage` as executable
|
||||
|
||||
---
|
||||
|
||||
## Testing Auto-Updates
|
||||
|
||||
1. **Local Testing:**
|
||||
- Build and package: `npm run package`
|
||||
- Create local update server or use GitHub Releases
|
||||
- Test with different versions
|
||||
|
||||
2. **GitHub Releases Testing:**
|
||||
- Create a draft release on GitHub
|
||||
- Publish with version tag (e.g., `v2.4.0`)
|
||||
- electron-builder automatically uploads assets
|
||||
- Test with previous version installed
|
||||
|
||||
---
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Phase 1: Add Update Notification** (Quick win)
|
||||
- Install `electron-updater`
|
||||
- Add basic update checking
|
||||
- Show notification with link to GitHub Releases
|
||||
- No auto-download, users download manually
|
||||
|
||||
2. **Phase 2: Enable Auto-Download** (Better UX)
|
||||
- Add download progress UI
|
||||
- Enable auto-download of updates
|
||||
- Add "Install and Restart" button
|
||||
|
||||
3. **Phase 3: Code Signing** (Production ready)
|
||||
- Acquire code signing certificates
|
||||
- Configure signing in electron-builder
|
||||
- Notarize macOS builds
|
||||
- Sign Windows builds
|
||||
|
||||
---
|
||||
|
||||
## Current Framework Update Flow (Already Working!)
|
||||
|
||||
For reference, here's how the existing Auto Claude framework updater works:
|
||||
|
||||
1. User opens **Settings > Advanced > Updates**
|
||||
2. App checks GitHub Releases API
|
||||
3. If update available, shows version + release notes
|
||||
4. User clicks "Download Update"
|
||||
5. Progress bar shows download status
|
||||
6. Update is extracted and applied to bundled source
|
||||
7. User configuration (.env) is preserved
|
||||
8. Done - no restart needed!
|
||||
|
||||
**This same UX could be replicated for app updates!**
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Status | User Experience |
|
||||
|-----------|--------|-----------------|
|
||||
| Auto Claude Framework | ✅ Working | One-click update in Settings |
|
||||
| Electron App (UI) | ❌ Missing | Must download from GitHub manually |
|
||||
|
||||
**Recommendation:** Implement electron-updater with update notifications (Phase 1) as a quick win. This will enable non-technical users to update the app without using git or terminal commands.
|
||||
|
||||
**Estimated effort:**
|
||||
- Phase 1 (Notifications): 2-4 hours
|
||||
- Phase 2 (Auto-download): 2-3 hours
|
||||
- Phase 3 (Code signing): Varies by platform
|
||||
|
||||
The existing framework updater code provides an excellent reference for the UI implementation!
|
||||
@@ -1,139 +0,0 @@
|
||||
# Windows/Linux Source Path Detection Fix
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows and Linux, when initializing a project, users were getting a "Source path not configured" error even though the `auto-claude` source directory exists. This error did not occur on macOS.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `detectAutoBuildSourcePath()` function in two files was using path resolution logic that worked on macOS in development mode but failed on Windows/Linux, especially in production/packaged builds. The function was trying to auto-detect where the Auto Claude framework source code (`auto-claude/` directory) is located, but the paths resolved differently across platforms.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Enhanced Path Detection Logic
|
||||
|
||||
Updated `detectAutoBuildSourcePath()` in two files:
|
||||
- `auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts`
|
||||
- `auto-claude-ui/src/main/ipc-handlers/project-handlers.ts`
|
||||
|
||||
**Key improvements:**
|
||||
|
||||
1. **Platform-aware path detection**: Separates development vs production mode using `is.dev` from `@electron-toolkit/utils`
|
||||
|
||||
2. **More comprehensive path checking**:
|
||||
- **Development mode**: Checks multiple relative paths from `__dirname`, `process.cwd()`, and parent directories
|
||||
- **Production mode**: Checks paths relative to `app.getAppPath()`, `process.resourcesPath`, and multiple levels up
|
||||
|
||||
3. **Debug logging**: Added detailed logging that can be enabled with `AUTO_CLAUDE_DEBUG=1` environment variable
|
||||
|
||||
4. **Better error messages**: Console warnings now guide users to enable debug mode if auto-detection fails
|
||||
|
||||
## Testing on Windows/Linux
|
||||
|
||||
### 1. Run with Debug Logging
|
||||
|
||||
Set the environment variable to see detailed path checking:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
$env:AUTO_CLAUDE_DEBUG="1"
|
||||
.\Auto-Claude.exe
|
||||
```
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```cmd
|
||||
set AUTO_CLAUDE_DEBUG=1
|
||||
Auto-Claude.exe
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
AUTO_CLAUDE_DEBUG=1 ./Auto-Claude
|
||||
```
|
||||
|
||||
### 2. Check Console Output
|
||||
|
||||
The debug output will show:
|
||||
- Current platform (win32/linux/darwin)
|
||||
- Whether running in dev or production mode
|
||||
- All paths being checked
|
||||
- Which paths exist and which don't
|
||||
- Whether auto-detection succeeded
|
||||
|
||||
Example debug output:
|
||||
```
|
||||
[detectAutoBuildSourcePath] Platform: win32
|
||||
[detectAutoBuildSourcePath] Is dev: false
|
||||
[detectAutoBuildSourcePath] __dirname: C:\Program Files\Auto-Claude\resources\app.asar\out\main
|
||||
[detectAutoBuildSourcePath] app.getAppPath(): C:\Program Files\Auto-Claude\resources\app.asar
|
||||
[detectAutoBuildSourcePath] process.cwd(): C:\Program Files\Auto-Claude
|
||||
[detectAutoBuildSourcePath] Checking paths: [...]
|
||||
[detectAutoBuildSourcePath] Checking C:\Program Files\auto-claude: ✗ not found
|
||||
[detectAutoBuildSourcePath] Checking C:\auto-claude: ✓ FOUND
|
||||
[detectAutoBuildSourcePath] Auto-detected source path: C:\auto-claude
|
||||
```
|
||||
|
||||
### 3. Manual Configuration (Fallback)
|
||||
|
||||
If auto-detection still fails, users can manually configure the path:
|
||||
|
||||
1. Open **App Settings** in Auto Claude UI
|
||||
2. Go to the **General** tab
|
||||
3. Set **Auto Claude Source Path** to the location of your `auto-claude` directory
|
||||
4. Click **Save**
|
||||
|
||||
Example paths:
|
||||
- Windows: `C:\Users\YourName\Projects\autonomous-coding\auto-claude`
|
||||
- Linux: `/home/yourname/projects/autonomous-coding/auto-claude`
|
||||
|
||||
## What Gets Checked
|
||||
|
||||
The function now checks these paths in order:
|
||||
|
||||
### Development Mode (`is.dev = true`):
|
||||
1. `__dirname/../../../auto-claude` - From out/main up 3 levels
|
||||
2. `__dirname/../../auto-claude` - From out/main up 2 levels
|
||||
3. `process.cwd()/auto-claude` - From current working directory
|
||||
4. `process.cwd()/../auto-claude` - From parent of cwd
|
||||
|
||||
### Production Mode (`is.dev = false`):
|
||||
1. `app.getAppPath()/../auto-claude` - Sibling to app
|
||||
2. `app.getAppPath()/../../auto-claude` - Up 2 from app
|
||||
3. `app.getAppPath()/../../../auto-claude` - Up 3 from app
|
||||
4. `process.resourcesPath/../auto-claude` - Relative to resources
|
||||
5. `process.resourcesPath/../../auto-claude` - Up 2 from resources
|
||||
|
||||
### All Modes:
|
||||
- `process.cwd()/auto-claude` - Last resort fallback
|
||||
|
||||
## Verification
|
||||
|
||||
For each path, the function checks:
|
||||
1. Does the directory exist?
|
||||
2. Does `VERSION` file exist inside it?
|
||||
|
||||
Both must be true for a path to be considered valid.
|
||||
|
||||
## Build Verification
|
||||
|
||||
The changes have been compiled and tested:
|
||||
```
|
||||
✓ Built successfully with no errors
|
||||
✓ All TypeScript files compiled
|
||||
✓ Electron app bundle created
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test on Windows**: Have Windows users test the updated build with `AUTO_CLAUDE_DEBUG=1`
|
||||
2. **Test on Linux**: Have Linux users test the updated build with `AUTO_CLAUDE_DEBUG=1`
|
||||
3. **Collect feedback**: If issues persist, the debug output will help identify the correct path patterns
|
||||
4. **Update documentation**: Add troubleshooting section to main README if needed
|
||||
|
||||
## Related Files
|
||||
|
||||
- `auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts`
|
||||
- `auto-claude-ui/src/main/ipc-handlers/project-handlers.ts`
|
||||
- `auto-claude-ui/src/main/project-initializer.ts`
|
||||
- `auto-claude-ui/src/renderer/App.tsx` (shows the error dialog)
|
||||
- `auto-claude-ui/src/renderer/components/Sidebar.tsx` (shows the error dialog)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Auto Claude UI Environment Variables
|
||||
# Copy this file to .env and set your values
|
||||
|
||||
# ============================================
|
||||
# DEBUG SETTINGS
|
||||
# ============================================
|
||||
|
||||
# Enable general debug logging for ideation and roadmap features
|
||||
# When enabled, you'll see detailed console logs for:
|
||||
# - Ideation generation and stop functionality
|
||||
# - Roadmap generation and stop functionality
|
||||
# - IPC communication between processes
|
||||
# - Store state updates
|
||||
# Usage: Set to 'true' before starting the app
|
||||
# DEBUG=true
|
||||
|
||||
# Enable debug logging for the auto-updater
|
||||
# Shows detailed information about app update checks and downloads
|
||||
# DEBUG_UPDATER=true
|
||||
|
||||
# Enable debug logging for Auto Claude features
|
||||
# Affects changelog generation, project initialization, and other core features
|
||||
# AUTO_CLAUDE_DEBUG=true
|
||||
|
||||
# ============================================
|
||||
# HOW TO USE
|
||||
# ============================================
|
||||
|
||||
# Option 1: Set in your shell before starting the app
|
||||
# DEBUG=true npm start
|
||||
#
|
||||
# Option 2: Export in your shell profile (~/.bashrc, ~/.zshrc, etc.)
|
||||
# export DEBUG=true
|
||||
# export AUTO_CLAUDE_DEBUG=true
|
||||
#
|
||||
# Option 3: Create a .env file in this directory (auto-claude-ui/)
|
||||
# Copy this file: cp .env.example .env
|
||||
# Then uncomment and set the variables you need
|
||||
#
|
||||
# Note: The Electron app will read these from process.env
|
||||
# The Python backend (auto-claude) has its own .env file
|
||||
|
||||
# ============================================
|
||||
# DEVELOPMENT
|
||||
# ============================================
|
||||
|
||||
# Node environment (automatically set by npm scripts)
|
||||
# NODE_ENV=development
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Auto Claude Team",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"start": "electron .",
|
||||
@@ -138,6 +138,18 @@
|
||||
{
|
||||
"from": "resources/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "../auto-claude",
|
||||
"to": "auto-claude",
|
||||
"filter": [
|
||||
"!**/.git",
|
||||
"!**/__pycache__",
|
||||
"!**/*.pyc",
|
||||
"!**/specs",
|
||||
"!**/.venv",
|
||||
"!**/.env"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Download prebuilt native modules for Windows
|
||||
*
|
||||
* This script downloads pre-compiled node-pty binaries from GitHub releases,
|
||||
* eliminating the need for Visual Studio Build Tools on Windows.
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const GITHUB_REPO = 'AndyMik90/Auto-Claude';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
|
||||
/**
|
||||
* Get the Electron ABI version for the installed Electron
|
||||
*/
|
||||
function getElectronAbi() {
|
||||
try {
|
||||
// Try to get from electron-abi package
|
||||
const result = execSync('npx electron-abi', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
// Fallback: read from electron package
|
||||
try {
|
||||
const electronPkg = require('electron/package.json');
|
||||
const version = electronPkg.version;
|
||||
// Electron 39.x = ABI 140
|
||||
const majorVersion = parseInt(version.split('.')[0], 10);
|
||||
// This is a rough mapping, electron-abi is more accurate
|
||||
const abiMap = {
|
||||
39: 140,
|
||||
38: 139,
|
||||
37: 136,
|
||||
36: 135,
|
||||
35: 134,
|
||||
34: 132,
|
||||
33: 131,
|
||||
32: 130,
|
||||
31: 129,
|
||||
30: 128,
|
||||
};
|
||||
return abiMap[majorVersion] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest release from GitHub
|
||||
*/
|
||||
function getLatestRelease() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: `/repos/${GITHUB_REPO}/releases/latest`,
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-Installer',
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
};
|
||||
|
||||
https
|
||||
.get(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(JSON.parse(data));
|
||||
} else if (res.statusCode === 404) {
|
||||
resolve(null); // No releases yet
|
||||
} else {
|
||||
reject(new Error(`GitHub API returned ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find prebuild asset in release
|
||||
*/
|
||||
function findPrebuildAsset(release, arch, electronAbi) {
|
||||
if (!release || !release.assets) return null;
|
||||
|
||||
const assetName = `node-pty-win32-${arch}-electron-${electronAbi}.zip`;
|
||||
return release.assets.find((asset) => asset.name === assetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from URL
|
||||
*/
|
||||
function downloadFile(url, destPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = (url) => {
|
||||
https
|
||||
.get(url, { headers: { 'User-Agent': 'Auto-Claude-Installer' } }, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
// Follow redirect
|
||||
request(res.headers.location);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`Download failed with status ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
.on('error', (err) => {
|
||||
fs.unlink(destPath, () => {}); // Delete partial file
|
||||
reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
request(url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip file (using built-in tools)
|
||||
*/
|
||||
function extractZip(zipPath, destDir) {
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Use PowerShell on Windows
|
||||
execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force"`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to download and install prebuilds
|
||||
*/
|
||||
async function downloadPrebuilds() {
|
||||
const arch = process.arch; // x64 or arm64
|
||||
const electronAbi = getElectronAbi();
|
||||
|
||||
if (!electronAbi) {
|
||||
console.log('[prebuilds] Could not determine Electron ABI version');
|
||||
return { success: false, reason: 'unknown-abi' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Looking for prebuilds: win32-${arch}, Electron ABI ${electronAbi}`);
|
||||
|
||||
// Check for prebuilds in GitHub releases
|
||||
let release;
|
||||
try {
|
||||
release = await getLatestRelease();
|
||||
} catch (err) {
|
||||
console.log(`[prebuilds] Could not fetch releases: ${err.message}`);
|
||||
return { success: false, reason: 'fetch-failed' };
|
||||
}
|
||||
|
||||
if (!release) {
|
||||
console.log('[prebuilds] No releases found');
|
||||
return { success: false, reason: 'no-releases' };
|
||||
}
|
||||
|
||||
const asset = findPrebuildAsset(release, arch, electronAbi);
|
||||
if (!asset) {
|
||||
console.log(`[prebuilds] No prebuild found for win32-${arch}-electron-${electronAbi}`);
|
||||
console.log('[prebuilds] Available assets:', release.assets?.map((a) => a.name).join(', ') || 'none');
|
||||
return { success: false, reason: 'no-matching-prebuild' };
|
||||
}
|
||||
|
||||
console.log(`[prebuilds] Found prebuild: ${asset.name}`);
|
||||
|
||||
// Download the prebuild
|
||||
const tempDir = path.join(__dirname, '..', '.prebuild-temp');
|
||||
const zipPath = path.join(tempDir, asset.name);
|
||||
const nodePtyDir = path.join(__dirname, '..', 'node_modules', 'node-pty');
|
||||
const buildDir = path.join(nodePtyDir, 'build', 'Release');
|
||||
|
||||
try {
|
||||
// Create temp directory
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
console.log(`[prebuilds] Downloading ${asset.name}...`);
|
||||
await downloadFile(asset.browser_download_url, zipPath);
|
||||
|
||||
console.log('[prebuilds] Extracting...');
|
||||
extractZip(zipPath, tempDir);
|
||||
|
||||
// Find the extracted prebuild directory
|
||||
const extractedDir = path.join(tempDir, 'prebuilds', `win32-${arch}-electron-${electronAbi}`);
|
||||
|
||||
if (!fs.existsSync(extractedDir)) {
|
||||
throw new Error(`Extracted directory not found: ${extractedDir}`);
|
||||
}
|
||||
|
||||
// Ensure build/Release directory exists
|
||||
fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// Copy files to node_modules/node-pty/build/Release
|
||||
const files = fs.readdirSync(extractedDir);
|
||||
for (const file of files) {
|
||||
const src = path.join(extractedDir, file);
|
||||
const dest = path.join(buildDir, file);
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`[prebuilds] Installed: ${file}`);
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log('[prebuilds] Successfully installed prebuilt binaries!');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`[prebuilds] Download/extract failed: ${err.message}`);
|
||||
return { success: false, reason: 'install-failed', error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use by postinstall
|
||||
module.exports = { downloadPrebuilds, getElectronAbi };
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
downloadPrebuilds()
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[prebuilds] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post-install script for Auto Claude UI
|
||||
*
|
||||
* On Windows:
|
||||
* 1. Try to download prebuilt node-pty binaries from GitHub releases
|
||||
* 2. Fall back to electron-rebuild if prebuilds aren't available
|
||||
* 3. Show helpful error message if compilation fails
|
||||
*
|
||||
* On macOS/Linux:
|
||||
* 1. Run electron-rebuild (compilers are typically available)
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
const WINDOWS_BUILD_TOOLS_HELP = `
|
||||
================================================================================
|
||||
VISUAL STUDIO BUILD TOOLS REQUIRED
|
||||
================================================================================
|
||||
|
||||
Prebuilt binaries weren't available for your Electron version, and compilation
|
||||
requires Visual Studio Build Tools.
|
||||
|
||||
To install:
|
||||
|
||||
1. Download Visual Studio Build Tools 2022:
|
||||
https://visualstudio.microsoft.com/visual-cpp-build-tools/
|
||||
|
||||
2. Run installer and select:
|
||||
- "Desktop development with C++" workload
|
||||
|
||||
3. In "Individual Components", also select:
|
||||
- "MSVC v143 - VS 2022 C++ x64/x86 Spectre-mitigated libs"
|
||||
|
||||
4. Restart your terminal and run: npm install
|
||||
|
||||
================================================================================
|
||||
`;
|
||||
|
||||
/**
|
||||
* Run electron-rebuild
|
||||
*/
|
||||
function runElectronRebuild() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const npx = isWindows ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(npx, ['electron-rebuild'], {
|
||||
stdio: 'inherit',
|
||||
shell: isWindows,
|
||||
cwd: path.join(__dirname, '..'),
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
reject(new Error(`electron-rebuild exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node-pty is already built
|
||||
*/
|
||||
function isNodePtyBuilt() {
|
||||
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) return false;
|
||||
|
||||
// Check for the main .node file
|
||||
const files = fs.readdirSync(buildDir);
|
||||
return files.some((f) => f.endsWith('.node'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main postinstall logic
|
||||
*/
|
||||
async function main() {
|
||||
console.log('[postinstall] Setting up native modules for Electron...\n');
|
||||
|
||||
// If node-pty is already built (e.g., from a previous successful install), skip
|
||||
if (isNodePtyBuilt()) {
|
||||
console.log('[postinstall] Native modules already built, skipping rebuild.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, try prebuilds first
|
||||
console.log('[postinstall] Windows detected - checking for prebuilt binaries...\n');
|
||||
|
||||
try {
|
||||
// Dynamic import to handle case where the script doesn't exist yet
|
||||
const { downloadPrebuilds } = require('./download-prebuilds.js');
|
||||
const result = await downloadPrebuilds();
|
||||
|
||||
if (result.success) {
|
||||
console.log('\n[postinstall] Successfully installed prebuilt binaries!');
|
||||
console.log('[postinstall] No Visual Studio Build Tools required.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[postinstall] Prebuilds not available (${result.reason})`);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
} catch (err) {
|
||||
console.log('[postinstall] Could not check for prebuilds:', err.message);
|
||||
console.log('[postinstall] Falling back to electron-rebuild...\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Run electron-rebuild
|
||||
try {
|
||||
console.log('[postinstall] Running electron-rebuild...\n');
|
||||
await runElectronRebuild();
|
||||
console.log('\n[postinstall] Native modules built successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n[postinstall] Failed to build native modules.\n');
|
||||
|
||||
if (isWindows) {
|
||||
console.error(WINDOWS_BUILD_TOOLS_HELP);
|
||||
} else {
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nYou may need to install build tools for your platform:');
|
||||
console.error(' macOS: xcode-select --install');
|
||||
console.error(' Linux: sudo apt-get install build-essential\n');
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[postinstall] Unexpected error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -29,6 +29,14 @@ vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(() => mockProcess)
|
||||
}));
|
||||
|
||||
// Mock claude-profile-manager to bypass auth checks in tests
|
||||
vi.mock('../../main/claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: () => ({
|
||||
hasValidAuth: () => true,
|
||||
getActiveProfile: () => ({ profileId: 'default', profileName: 'Default' })
|
||||
})
|
||||
}));
|
||||
|
||||
// Auto-claude source path (for getAutoBuildSourcePath to find)
|
||||
const AUTO_CLAUDE_SOURCE = path.join(TEST_DIR, 'auto-claude-source');
|
||||
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Unit tests for rate limit and auth failure detection
|
||||
* Tests detection patterns for rate limiting and authentication failures
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock the claude-profile-manager before importing
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(() => ({
|
||||
getActiveProfile: vi.fn(() => ({
|
||||
id: 'test-profile-id',
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getProfile: vi.fn((id: string) => ({
|
||||
id,
|
||||
name: 'Test Profile',
|
||||
isDefault: true
|
||||
})),
|
||||
getBestAvailableProfile: vi.fn(() => null),
|
||||
recordRateLimitEvent: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('Rate Limit Detector', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectRateLimit', () => {
|
||||
it('should detect rate limit with reset time', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
expect(result.limitType).toBe('weekly');
|
||||
});
|
||||
|
||||
it('should detect rate limit with bullet character', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached • resets 11:59pm';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
expect(result.resetTime).toBe('11:59pm');
|
||||
expect(result.limitType).toBe('session');
|
||||
});
|
||||
|
||||
it('should detect secondary rate limit indicators', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'rate limit exceeded',
|
||||
'usage limit reached',
|
||||
'You have exceeded your limit',
|
||||
'too many requests'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectRateLimit(output);
|
||||
expect(result.isRateLimited).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectRateLimit(output);
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectRateLimit('');
|
||||
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('Limit reached · resets Dec 17 at 6am')).toBe(true);
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', async () => {
|
||||
const { isRateLimitError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isRateLimitError('authentication required')).toBe(false);
|
||||
expect(isRateLimitError('Task completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractResetTime', () => {
|
||||
it('should extract reset time from rate limit message', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am (Europe/Oslo)';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBe('Dec 17 at 6am (Europe/Oslo)');
|
||||
});
|
||||
|
||||
it('should return null for non-rate-limit output', async () => {
|
||||
const { extractResetTime } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const resetTime = extractResetTime(output);
|
||||
|
||||
expect(resetTime).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Failure Detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectAuthFailure', () => {
|
||||
it('should detect "authentication required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
expect(result.message).toContain('authentication required');
|
||||
});
|
||||
|
||||
it('should detect "authentication is required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication is required to proceed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: not authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "not yet authenticated" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'You are not yet authenticated';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "login required" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Login required';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "oauth token invalid" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token is invalid';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "oauth token expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "oauth token missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'OAuth token missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "please log in" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please log in to continue';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please log in" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "please authenticate" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Please authenticate before proceeding';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
// "please authenticate" doesn't contain 'required' keyword, so classified as 'unknown'
|
||||
expect(result.failureType).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "invalid credentials" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid credentials provided';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Invalid token';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "auth failed" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Auth failed';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "authentication error" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Authentication error occurred';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "session expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Your session expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Access denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Permission denied';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "401 unauthorized" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'HTTP 401 Unauthorized';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should detect "credentials missing" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials are missing';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('missing');
|
||||
});
|
||||
|
||||
it('should detect "credentials expired" pattern', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Credentials expired';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.failureType).toBe('expired');
|
||||
});
|
||||
|
||||
it('should return false for rate limit errors (not auth failure)', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Limit reached · resets Dec 17 at 6am';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for normal output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Task completed successfully';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty output', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('');
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
|
||||
it('should include profile ID in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required', 'custom-profile');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('custom-profile');
|
||||
});
|
||||
|
||||
it('should use active profile ID when not specified', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.profileId).toBe('test-profile-id');
|
||||
});
|
||||
|
||||
it('should include original error in result', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: authentication required for this action';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.originalError).toBe(output);
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for missing auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('authentication required');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Settings');
|
||||
expect(result.message).toContain('Claude Profiles');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for expired auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('session expired');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('expired');
|
||||
expect(result.message).toContain('re-authenticate');
|
||||
});
|
||||
|
||||
it('should provide user-friendly message for invalid auth', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectAuthFailure('unauthorized');
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
expect(result.message).toContain('Invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthFailureError', () => {
|
||||
it('should return true for auth failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('authentication required')).toBe(true);
|
||||
expect(isAuthFailureError('not authenticated')).toBe(true);
|
||||
expect(isAuthFailureError('unauthorized')).toBe(true);
|
||||
expect(isAuthFailureError('invalid token')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth-failure errors', async () => {
|
||||
const { isAuthFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isAuthFailureError('Limit reached · resets Dec 17')).toBe(false);
|
||||
expect(isAuthFailureError('Task completed')).toBe(false);
|
||||
expect(isAuthFailureError('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth failure does not match rate limit patterns', () => {
|
||||
it('should not detect auth failure as rate limit', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const authErrors = [
|
||||
'authentication required',
|
||||
'not authenticated',
|
||||
'unauthorized',
|
||||
'invalid token',
|
||||
'session expired',
|
||||
'please log in'
|
||||
];
|
||||
|
||||
for (const error of authErrors) {
|
||||
const result = detectRateLimit(error);
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not detect rate limit as auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const rateLimitErrors = [
|
||||
'Limit reached · resets Dec 17 at 6am',
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
'usage limit reached'
|
||||
];
|
||||
|
||||
for (const error of rateLimitErrors) {
|
||||
const result = detectAuthFailure(error);
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline output with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Starting task...
|
||||
Processing...
|
||||
Error: authentication required
|
||||
Please authenticate and try again.`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'AUTHENTICATION REQUIRED',
|
||||
'Authentication Required',
|
||||
'UNAUTHORIZED',
|
||||
'Unauthorized',
|
||||
'NOT AUTHENTICATED',
|
||||
'Not Authenticated'
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectAuthFailure(output);
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle partial matches correctly', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
// Should NOT match - word is part of a different context
|
||||
const falsePositives = [
|
||||
'The authenticated user can proceed', // has 'authenticated' but not an error
|
||||
'Authorization header set correctly' // different word
|
||||
];
|
||||
|
||||
// Note: Some false positives may still match due to pattern design
|
||||
// The patterns are intentionally broad to catch errors
|
||||
for (const output of falsePositives) {
|
||||
const result = detectAuthFailure(output);
|
||||
// Just verify it runs without error - actual match depends on pattern design
|
||||
expect(typeof result.isAuthFailure).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle JSON error responses', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = '{"error": "unauthorized", "message": "Please authenticate"}';
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error stack traces with auth failure', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Error: authentication required
|
||||
at validateToken (/app/auth.js:42)
|
||||
at processRequest (/app/handler.js:15)
|
||||
at main (/app/index.js:8)`;
|
||||
|
||||
const result = detectAuthFailure(output);
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
@@ -89,6 +90,13 @@ export class AgentManager extends EventEmitter {
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -120,6 +128,20 @@ export class AgentManager extends EventEmitter {
|
||||
args.push('--auto-approve');
|
||||
}
|
||||
|
||||
// Pass model and thinking level configuration
|
||||
// For auto profile, use phase-specific config; otherwise use single model/thinking
|
||||
if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
|
||||
// Pass the spec phase model and thinking level to spec_runner
|
||||
args.push('--model', metadata.phaseModels.spec);
|
||||
args.push('--thinking-level', metadata.phaseThinking.spec);
|
||||
} else if (metadata?.model) {
|
||||
// Non-auto profile: use single model and thinking level
|
||||
args.push('--model', metadata.model);
|
||||
if (metadata.thinkingLevel) {
|
||||
args.push('--thinking-level', metadata.thinkingLevel);
|
||||
}
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata);
|
||||
|
||||
@@ -136,6 +158,13 @@ export class AgentManager extends EventEmitter {
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
): void {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
this.emit('error', taskId, 'Claude authentication required. Please authenticate in Settings > Claude Profiles before starting tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
@@ -168,6 +197,8 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
// Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent
|
||||
// The options.parallel and options.workers are kept for future use or logging purposes
|
||||
// Note: Model configuration is read from task_metadata.json by the Python scripts,
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
@@ -250,6 +281,20 @@ export class AgentManager extends EventEmitter {
|
||||
return this.queueManager.isIdeationRunning(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop roadmap generation for a project
|
||||
*/
|
||||
stopRoadmap(projectId: string): boolean {
|
||||
return this.queueManager.stopRoadmap(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if roadmap is running for a project
|
||||
*/
|
||||
isRoadmapRunning(projectId: string): boolean {
|
||||
return this.queueManager.isRoadmapRunning(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running processes
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
|
||||
@@ -300,6 +300,17 @@ export class AgentProcessManager {
|
||||
taskId
|
||||
});
|
||||
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
|
||||
} else {
|
||||
// Not rate limited - check for authentication failure
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { IdeationConfig } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Queue management for ideation and roadmap generation
|
||||
@@ -38,16 +39,25 @@ export class AgentQueueManager {
|
||||
refresh: boolean = false,
|
||||
enableCompetitorAnalysis: boolean = false
|
||||
): void {
|
||||
debugLog('[Agent Queue] Starting roadmap generation:', {
|
||||
projectId,
|
||||
projectPath,
|
||||
refresh,
|
||||
enableCompetitorAnalysis
|
||||
});
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
debugError('[Agent Queue] Auto-build source path not found');
|
||||
this.emitter.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py');
|
||||
const roadmapRunnerPath = path.join(autoBuildSource, 'runners', 'roadmap_runner.py');
|
||||
|
||||
if (!existsSync(roadmapRunnerPath)) {
|
||||
debugError('[Agent Queue] Roadmap runner not found at:', roadmapRunnerPath);
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +73,8 @@ export class AgentQueueManager {
|
||||
args.push('--competitor-analysis');
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning roadmap process with args:', args);
|
||||
|
||||
// Use projectId as taskId for roadmap operations
|
||||
this.spawnRoadmapProcess(projectId, projectPath, args);
|
||||
}
|
||||
@@ -76,16 +88,25 @@ export class AgentQueueManager {
|
||||
config: IdeationConfig,
|
||||
refresh: boolean = false
|
||||
): void {
|
||||
debugLog('[Agent Queue] Starting ideation generation:', {
|
||||
projectId,
|
||||
projectPath,
|
||||
config,
|
||||
refresh
|
||||
});
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
|
||||
if (!autoBuildSource) {
|
||||
debugError('[Agent Queue] Auto-build source path not found');
|
||||
this.emitter.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py');
|
||||
const ideationRunnerPath = path.join(autoBuildSource, 'runners', 'ideation_runner.py');
|
||||
|
||||
if (!existsSync(ideationRunnerPath)) {
|
||||
debugError('[Agent Queue] Ideation runner not found at:', ideationRunnerPath);
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`);
|
||||
return;
|
||||
}
|
||||
@@ -119,6 +140,8 @@ export class AgentQueueManager {
|
||||
args.push('--append');
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning ideation process with args:', args);
|
||||
|
||||
// Use projectId as taskId for ideation operations
|
||||
this.spawnIdeationProcess(projectId, projectPath, args);
|
||||
}
|
||||
@@ -131,11 +154,17 @@ export class AgentQueueManager {
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
debugLog('[Agent Queue] Spawning ideation process:', { projectId, projectPath });
|
||||
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
const wasKilled = this.processManager.killProcess(projectId);
|
||||
if (wasKilled) {
|
||||
debugLog('[Agent Queue] Killed existing process for project:', projectId);
|
||||
}
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
debugLog('[Agent Queue] Generated spawn ID:', spawnId);
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
@@ -144,22 +173,42 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
// Build final environment with proper precedence:
|
||||
// 1. process.env (system)
|
||||
// 2. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 3. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 4. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
// Debug: Show OAuth token source
|
||||
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
|
||||
? 'Electron app profile'
|
||||
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
|
||||
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
const hasToken = !!oauthToken;
|
||||
debugLog('[Agent Queue] OAuth token status:', {
|
||||
source: tokenSource,
|
||||
hasToken,
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
}
|
||||
env: finalEnv
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
@@ -167,7 +216,8 @@ export class AgentQueueManager {
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
projectPath, // Store project path for loading session on completion
|
||||
spawnId
|
||||
spawnId,
|
||||
queueProcessType: 'ideation'
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
@@ -206,6 +256,13 @@ export class AgentQueueManager {
|
||||
const [, ideationType, ideasCount] = typeCompleteMatch;
|
||||
completedTypes.add(ideationType);
|
||||
|
||||
debugLog('[Agent Queue] Ideation type completed:', {
|
||||
projectId,
|
||||
ideationType,
|
||||
ideasCount: parseInt(ideasCount, 10),
|
||||
totalCompleted: completedTypes.size
|
||||
});
|
||||
|
||||
// Emit event for UI to load this type's ideas immediately
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
|
||||
}
|
||||
@@ -214,6 +271,8 @@ export class AgentQueueManager {
|
||||
if (typeFailedMatch) {
|
||||
const [, ideationType] = typeFailedMatch;
|
||||
completedTypes.add(ideationType);
|
||||
|
||||
debugError('[Agent Queue] Ideation type failed:', { projectId, ideationType });
|
||||
this.emitter.emit('ideation-type-failed', projectId, ideationType);
|
||||
}
|
||||
|
||||
@@ -254,6 +313,8 @@ export class AgentQueueManager {
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Ideation process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
@@ -261,8 +322,10 @@ export class AgentQueueManager {
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
|
||||
const rateLimitDetection = detectRateLimit(allOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
debugLog('[Agent Queue] Rate limit detected for ideation');
|
||||
const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
@@ -271,6 +334,7 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
debugLog('[Agent Queue] Ideation generation completed successfully');
|
||||
this.emitter.emit('ideation-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
@@ -286,18 +350,25 @@ export class AgentQueueManager {
|
||||
'ideation',
|
||||
'ideation.json'
|
||||
);
|
||||
debugLog('[Agent Queue] Loading ideation session from:', ideationFilePath);
|
||||
if (existsSync(ideationFilePath)) {
|
||||
const content = readFileSync(ideationFilePath, 'utf-8');
|
||||
const session = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded ideation session:', {
|
||||
totalIdeas: session.ideas?.length || 0
|
||||
});
|
||||
this.emitter.emit('ideation-complete', projectId, session);
|
||||
} else {
|
||||
debugError('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Ideation] Failed to load ideation session:', err);
|
||||
console.error('[Ideation] Failed to load ideation session:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Ideation generation failed:', { projectId, code });
|
||||
this.emitter.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
@@ -318,11 +389,17 @@ export class AgentQueueManager {
|
||||
projectPath: string,
|
||||
args: string[]
|
||||
): void {
|
||||
debugLog('[Agent Queue] Spawning roadmap process:', { projectId, projectPath });
|
||||
|
||||
// Kill existing process for this project if any
|
||||
this.processManager.killProcess(projectId);
|
||||
const wasKilled = this.processManager.killProcess(projectId);
|
||||
if (wasKilled) {
|
||||
debugLog('[Agent Queue] Killed existing roadmap process for project:', projectId);
|
||||
}
|
||||
|
||||
// Generate unique spawn ID for this process instance
|
||||
const spawnId = this.state.generateSpawnId();
|
||||
debugLog('[Agent Queue] Generated roadmap spawn ID:', spawnId);
|
||||
|
||||
// Run from auto-claude source directory so imports work correctly
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
@@ -331,22 +408,42 @@ export class AgentQueueManager {
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
// Get Python path from process manager (uses venv if configured)
|
||||
const pythonPath = this.processManager.getPythonPath();
|
||||
|
||||
// Build final environment with proper precedence:
|
||||
// 1. process.env (system)
|
||||
// 2. combinedEnv (auto-claude/.env for CLI usage)
|
||||
// 3. profileEnv (Electron app OAuth token - highest priority)
|
||||
// 4. Our specific overrides
|
||||
const finalEnv = {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONPATH: autoBuildSource || '', // Allow imports from auto-claude directory
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
};
|
||||
|
||||
// Debug: Show OAuth token source
|
||||
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
|
||||
? 'Electron app profile'
|
||||
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
|
||||
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
const hasToken = !!oauthToken;
|
||||
debugLog('[Agent Queue] OAuth token status:', {
|
||||
source: tokenSource,
|
||||
hasToken,
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...combinedEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
}
|
||||
env: finalEnv
|
||||
});
|
||||
|
||||
this.state.addProcess(projectId, {
|
||||
@@ -354,7 +451,8 @@ export class AgentQueueManager {
|
||||
process: childProcess,
|
||||
startedAt: new Date(),
|
||||
projectPath, // Store project path for loading roadmap on completion
|
||||
spawnId
|
||||
spawnId,
|
||||
queueProcessType: 'roadmap'
|
||||
});
|
||||
|
||||
// Track progress through output
|
||||
@@ -412,6 +510,8 @@ export class AgentQueueManager {
|
||||
|
||||
// Handle process exit
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Roadmap process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
@@ -419,8 +519,10 @@ export class AgentQueueManager {
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
debugLog('[Agent Queue] Checking for rate limit (non-zero exit)');
|
||||
const rateLimitDetection = detectRateLimit(allRoadmapOutput);
|
||||
if (rateLimitDetection.isRateLimited) {
|
||||
debugLog('[Agent Queue] Rate limit detected for roadmap');
|
||||
const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, {
|
||||
projectId
|
||||
});
|
||||
@@ -429,6 +531,7 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
debugLog('[Agent Queue] Roadmap generation completed successfully');
|
||||
this.emitter.emit('roadmap-progress', projectId, {
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
@@ -444,18 +547,26 @@ export class AgentQueueManager {
|
||||
'roadmap',
|
||||
'roadmap.json'
|
||||
);
|
||||
debugLog('[Agent Queue] Loading roadmap from:', roadmapFilePath);
|
||||
if (existsSync(roadmapFilePath)) {
|
||||
const content = readFileSync(roadmapFilePath, 'utf-8');
|
||||
const roadmap = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded roadmap:', {
|
||||
featuresCount: roadmap.features?.length || 0,
|
||||
phasesCount: roadmap.phases?.length || 0
|
||||
});
|
||||
this.emitter.emit('roadmap-complete', projectId, roadmap);
|
||||
} else {
|
||||
debugError('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
console.warn('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Roadmap] Failed to load roadmap:', err);
|
||||
console.error('[Roadmap] Failed to load roadmap:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Roadmap generation failed:', { projectId, code });
|
||||
this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`);
|
||||
}
|
||||
});
|
||||
@@ -472,12 +583,19 @@ export class AgentQueueManager {
|
||||
* Stop ideation generation for a project
|
||||
*/
|
||||
stopIdeation(projectId: string): boolean {
|
||||
const wasRunning = this.state.hasProcess(projectId);
|
||||
if (wasRunning) {
|
||||
debugLog('[Agent Queue] Stop ideation requested:', { projectId });
|
||||
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const isIdeation = processInfo?.queueProcessType === 'ideation';
|
||||
debugLog('[Agent Queue] Process running?', { projectId, isIdeation, processType: processInfo?.queueProcessType });
|
||||
|
||||
if (isIdeation) {
|
||||
debugLog('[Agent Queue] Killing ideation process:', projectId);
|
||||
this.processManager.killProcess(projectId);
|
||||
this.emitter.emit('ideation-stopped', projectId);
|
||||
return true;
|
||||
}
|
||||
debugLog('[Agent Queue] No running ideation process found for:', projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -485,6 +603,35 @@ export class AgentQueueManager {
|
||||
* Check if ideation is running for a project
|
||||
*/
|
||||
isIdeationRunning(projectId: string): boolean {
|
||||
return this.state.hasProcess(projectId);
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
return processInfo?.queueProcessType === 'ideation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop roadmap generation for a project
|
||||
*/
|
||||
stopRoadmap(projectId: string): boolean {
|
||||
debugLog('[Agent Queue] Stop roadmap requested:', { projectId });
|
||||
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const isRoadmap = processInfo?.queueProcessType === 'roadmap';
|
||||
debugLog('[Agent Queue] Roadmap process running?', { projectId, isRoadmap, processType: processInfo?.queueProcessType });
|
||||
|
||||
if (isRoadmap) {
|
||||
debugLog('[Agent Queue] Killing roadmap process:', projectId);
|
||||
this.processManager.killProcess(projectId);
|
||||
this.emitter.emit('roadmap-stopped', projectId);
|
||||
return true;
|
||||
}
|
||||
debugLog('[Agent Queue] No running roadmap process found for:', projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if roadmap is running for a project
|
||||
*/
|
||||
isRoadmapRunning(projectId: string): boolean {
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
return processInfo?.queueProcessType === 'roadmap';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ChildProcess } from 'child_process';
|
||||
* Agent-specific types for process and state management
|
||||
*/
|
||||
|
||||
export type QueueProcessType = 'ideation' | 'roadmap';
|
||||
|
||||
export interface AgentProcess {
|
||||
taskId: string;
|
||||
process: ChildProcess;
|
||||
startedAt: Date;
|
||||
projectPath?: string; // For ideation processes to load session on completion
|
||||
spawnId: number; // Unique ID to identify this specific spawn
|
||||
queueProcessType?: QueueProcessType; // Type of queue process (ideation or roadmap)
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
@@ -45,6 +48,23 @@ export interface TaskExecutionOptions {
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
// Auto profile - phase-based model and thinking configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: {
|
||||
spec: 'haiku' | 'sonnet' | 'opus';
|
||||
planning: 'haiku' | 'sonnet' | 'opus';
|
||||
coding: 'haiku' | 'sonnet' | 'opus';
|
||||
qa: 'haiku' | 'sonnet' | 'opus';
|
||||
};
|
||||
phaseThinking?: {
|
||||
spec: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
};
|
||||
// Non-auto profile - single model and thinking level
|
||||
model?: 'haiku' | 'sonnet' | 'opus';
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -451,6 +451,34 @@ export class ClaudeProfileManager {
|
||||
return isProfileAuthenticatedImpl(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a profile has valid authentication for starting tasks.
|
||||
* A profile is considered authenticated if:
|
||||
* 1) It has a valid OAuth token (not expired), OR
|
||||
* 2) It has an authenticated configDir (credential files exist)
|
||||
*
|
||||
* @param profileId - Optional profile ID to check. If not provided, checks active profile.
|
||||
* @returns true if the profile can authenticate, false otherwise
|
||||
*/
|
||||
hasValidAuth(profileId?: string): boolean {
|
||||
const profile = profileId ? this.getProfile(profileId) : this.getActiveProfile();
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 1: Profile has a valid OAuth token
|
||||
if (hasValidToken(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2 & 3: Profile has authenticated configDir (works for both default and non-default)
|
||||
if (this.isProfileAuthenticated(profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for invoking Claude with a specific profile
|
||||
*/
|
||||
|
||||
@@ -139,6 +139,21 @@ app.whenReady().then(() => {
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started');
|
||||
|
||||
// Log debug mode status
|
||||
const isDebugMode = process.env.DEBUG === 'true';
|
||||
const isAutoClaudeDebug = process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
if (isDebugMode || isAutoClaudeDebug) {
|
||||
console.warn('[main] ========================================');
|
||||
console.warn('[main] DEBUG MODE ENABLED');
|
||||
if (isDebugMode) {
|
||||
console.warn('[main] - DEBUG=true (Ideation/Roadmap debug logging)');
|
||||
}
|
||||
if (isAutoClaudeDebug) {
|
||||
console.warn('[main] - AUTO_CLAUDE_DEBUG=true (Core features debug logging)');
|
||||
}
|
||||
console.warn('[main] ========================================');
|
||||
}
|
||||
|
||||
// Initialize app auto-updater (only in production, or when DEBUG_UPDATER is set)
|
||||
const forceUpdater = process.env.DEBUG_UPDATER === 'true';
|
||||
if (app.isPackaged || forceUpdater) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { EventEmitter } from 'events';
|
||||
import type {
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatMessage
|
||||
InsightsChatMessage,
|
||||
InsightsModelConfig
|
||||
} from '../shared/types';
|
||||
import { InsightsConfig } from './insights/config';
|
||||
import { InsightsPaths } from './insights/paths';
|
||||
@@ -111,7 +112,12 @@ export class InsightsService extends EventEmitter {
|
||||
/**
|
||||
* Send a message and get AI response
|
||||
*/
|
||||
async sendMessage(projectId: string, projectPath: string, message: string): Promise<void> {
|
||||
async sendMessage(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<void> {
|
||||
// Cancel any existing session
|
||||
this.executor.cancelSession(projectId);
|
||||
|
||||
@@ -150,13 +156,17 @@ export class InsightsService extends EventEmitter {
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
// Use provided modelConfig or fall back to session's config
|
||||
const configToUse = modelConfig || session.modelConfig;
|
||||
|
||||
try {
|
||||
// Execute insights query
|
||||
const result = await this.executor.execute(
|
||||
projectId,
|
||||
projectPath,
|
||||
message,
|
||||
conversationHistory
|
||||
conversationHistory,
|
||||
configToUse
|
||||
);
|
||||
|
||||
// Add assistant message to session
|
||||
@@ -177,6 +187,13 @@ export class InsightsService extends EventEmitter {
|
||||
console.error('[InsightsService] Error executing insights:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
return this.sessionManager.updateSessionModelConfig(projectPath, sessionId, modelConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
InsightsChatMessage,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsToolUsage
|
||||
InsightsToolUsage,
|
||||
InsightsModelConfig
|
||||
} from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { InsightsConfig } from './config';
|
||||
import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector';
|
||||
|
||||
@@ -59,7 +61,8 @@ export class InsightsExecutor extends EventEmitter {
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
message: string,
|
||||
conversationHistory: Array<{ role: string; content: string }>
|
||||
conversationHistory: Array<{ role: string; content: string }>,
|
||||
modelConfig?: InsightsModelConfig
|
||||
): Promise<ProcessorResult> {
|
||||
// Cancel any existing session
|
||||
this.cancelSession(projectId);
|
||||
@@ -69,7 +72,7 @@ export class InsightsExecutor extends EventEmitter {
|
||||
throw new Error('Auto Claude source not found');
|
||||
}
|
||||
|
||||
const runnerPath = path.join(autoBuildSource, 'insights_runner.py');
|
||||
const runnerPath = path.join(autoBuildSource, 'runners', 'insights_runner.py');
|
||||
if (!existsSync(runnerPath)) {
|
||||
throw new Error('insights_runner.py not found in auto-claude directory');
|
||||
}
|
||||
@@ -83,13 +86,23 @@ export class InsightsExecutor extends EventEmitter {
|
||||
// Get process environment
|
||||
const processEnv = this.config.getProcessEnv();
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), [
|
||||
// Build command arguments
|
||||
const args = [
|
||||
runnerPath,
|
||||
'--project-dir', projectPath,
|
||||
'--message', message,
|
||||
'--history', JSON.stringify(conversationHistory)
|
||||
], {
|
||||
];
|
||||
|
||||
// Add model config if provided
|
||||
if (modelConfig) {
|
||||
const modelId = MODEL_ID_MAP[modelConfig.model] || MODEL_ID_MAP['sonnet'];
|
||||
args.push('--model', modelId);
|
||||
args.push('--thinking-level', modelConfig.thinkingLevel);
|
||||
}
|
||||
|
||||
// Spawn Python process
|
||||
const proc = spawn(this.config.getPythonPath(), args, {
|
||||
cwd: autoBuildSource,
|
||||
env: processEnv
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InsightsSession, InsightsSessionSummary } from '../../shared/types';
|
||||
import type { InsightsSession, InsightsSessionSummary, InsightsModelConfig } from '../../shared/types';
|
||||
import { SessionStorage } from './session-storage';
|
||||
import { InsightsPaths } from './paths';
|
||||
|
||||
@@ -119,6 +119,30 @@ export class SessionManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update model configuration for a session
|
||||
*/
|
||||
updateSessionModelConfig(projectPath: string, sessionId: string, modelConfig: InsightsModelConfig): boolean {
|
||||
const session = this.storage.loadSessionById(projectPath, sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
session.modelConfig = modelConfig;
|
||||
session.updatedAt = new Date();
|
||||
this.storage.saveSession(projectPath, session);
|
||||
|
||||
// Update cache if this session is cached
|
||||
for (const [projectId, cachedSession] of this.sessions) {
|
||||
if (cachedSession.id === sessionId) {
|
||||
cachedSession.modelConfig = modelConfig;
|
||||
cachedSession.updatedAt = new Date();
|
||||
this.sessions.set(projectId, cachedSession);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session to disk and update cache
|
||||
*/
|
||||
|
||||
@@ -47,11 +47,12 @@ export function registerImportIssues(agentManager: AgentManager): void {
|
||||
};
|
||||
|
||||
// Build description with metadata
|
||||
const labels = issue.labels.map(l => l.name).join(', ');
|
||||
const labelNames = issue.labels.map(l => l.name);
|
||||
const labelsString = labelNames.join(', ');
|
||||
const description = `# ${issue.title}
|
||||
|
||||
**GitHub Issue:** [#${issue.number}](${issue.html_url})
|
||||
${labels ? `**Labels:** ${labels}` : ''}
|
||||
${labelsString ? `**Labels:** ${labelsString}` : ''}
|
||||
|
||||
## Description
|
||||
|
||||
@@ -64,7 +65,8 @@ ${issue.body || 'No description provided.'}
|
||||
issue.number,
|
||||
issue.title,
|
||||
description,
|
||||
issue.html_url
|
||||
issue.html_url,
|
||||
labelNames
|
||||
);
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
|
||||
@@ -66,7 +66,7 @@ export function registerInvestigateIssue(
|
||||
): void {
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE,
|
||||
async (_, projectId: string, issueNumber: number) => {
|
||||
async (_, projectId: string, issueNumber: number, selectedCommentIds?: number[]) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
@@ -104,11 +104,16 @@ export function registerInvestigateIssue(
|
||||
};
|
||||
|
||||
// Fetch issue comments for more context
|
||||
const comments = await githubFetch(
|
||||
const allComments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}/comments`
|
||||
) as GitHubAPIComment[];
|
||||
|
||||
// Filter comments based on selection (if provided)
|
||||
const comments = selectedCommentIds && selectedCommentIds.length > 0
|
||||
? allComments.filter(c => selectedCommentIds.includes(c.id))
|
||||
: allComments;
|
||||
|
||||
// Build context for the AI investigation
|
||||
const labels = issue.labels.map(l => l.name);
|
||||
const issueContext = buildIssueContext(
|
||||
@@ -141,17 +146,13 @@ export function registerInvestigateIssue(
|
||||
issue.number,
|
||||
issue.title,
|
||||
taskDescription,
|
||||
issue.html_url
|
||||
issue.html_url,
|
||||
labels
|
||||
);
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
agentManager.startSpecCreation(
|
||||
specData.specId,
|
||||
project.path,
|
||||
specData.taskDescription,
|
||||
specData.specDir,
|
||||
specData.metadata
|
||||
);
|
||||
// NOTE: We intentionally do NOT call agentManager.startSpecCreation() here
|
||||
// This allows the task to stay in "backlog" status until the user manually starts it
|
||||
// Previously, calling startSpecCreation would auto-start the task immediately
|
||||
|
||||
// Phase 3: Creating task
|
||||
sendProgress(mainWindow, projectId, {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, GitHubIssue } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import type { GitHubAPIIssue } from './types';
|
||||
import type { GitHubAPIIssue, GitHubAPIComment } from './types';
|
||||
|
||||
/**
|
||||
* Transform GitHub API issue to application format
|
||||
@@ -116,10 +116,45 @@ export function registerGetIssue(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for a specific issue
|
||||
*/
|
||||
export function registerGetIssueComments(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_ISSUE_COMMENTS,
|
||||
async (_, projectId: string, issueNumber: number): Promise<IPCResult<GitHubAPIComment[]>> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { success: false, error: 'No GitHub token or repository configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const comments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}/comments`
|
||||
) as GitHubAPIComment[];
|
||||
|
||||
return { success: true, data: comments };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch issue comments'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all issue-related handlers
|
||||
*/
|
||||
export function registerIssueHandlers(): void {
|
||||
registerGetIssues();
|
||||
registerGetIssue();
|
||||
registerGetIssueComments();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
|
||||
@@ -21,6 +21,18 @@ function debugLog(message: string, data?: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Regex pattern to validate GitHub repository format (owner/repo)
|
||||
// Allows alphanumeric characters, hyphens, underscores, and periods
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
/**
|
||||
* Validate that a repository string matches the expected owner/repo format
|
||||
* Prevents command injection by rejecting strings with shell metacharacters
|
||||
*/
|
||||
function isValidGitHubRepo(repo: string): boolean {
|
||||
return GITHUB_REPO_PATTERN.test(repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is installed
|
||||
*/
|
||||
@@ -296,6 +308,106 @@ export function registerListUserRepos(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GitHub repository from git remote origin
|
||||
*/
|
||||
export function registerDetectGitHubRepo(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_DETECT_REPO,
|
||||
async (_event: Electron.IpcMainInvokeEvent, projectPath: string): Promise<IPCResult<string>> => {
|
||||
debugLog('detectGitHubRepo handler called', { projectPath });
|
||||
try {
|
||||
// Get the remote URL
|
||||
debugLog('Running: git remote get-url origin');
|
||||
const remoteUrl = execSync('git remote get-url origin', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectPath,
|
||||
stdio: 'pipe'
|
||||
}).trim();
|
||||
|
||||
debugLog('Remote URL:', remoteUrl);
|
||||
|
||||
// Parse GitHub repo from URL
|
||||
// Formats:
|
||||
// - https://github.com/owner/repo.git
|
||||
// - git@github.com:owner/repo.git
|
||||
// - https://github.com/owner/repo
|
||||
const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const repo = match[1];
|
||||
debugLog('Detected repo:', repo);
|
||||
return {
|
||||
success: true,
|
||||
data: repo
|
||||
};
|
||||
}
|
||||
|
||||
debugLog('Could not parse GitHub repo from URL');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Remote URL is not a GitHub repository'
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to detect repo:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to detect GitHub repository'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branches from GitHub repository
|
||||
*/
|
||||
export function registerGetGitHubBranches(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_GET_BRANCHES,
|
||||
async (_event: Electron.IpcMainInvokeEvent, repo: string, _token: string): Promise<IPCResult<string[]>> => {
|
||||
debugLog('getGitHubBranches handler called', { repo });
|
||||
|
||||
// Validate repo format to prevent command injection
|
||||
if (!isValidGitHubRepo(repo)) {
|
||||
debugLog('Invalid repo format rejected:', repo);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Expected: owner/repo'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Use gh CLI to list branches (uses authenticated session)
|
||||
// Use execFileSync with separate arguments to avoid shell injection
|
||||
const apiEndpoint = `repos/${repo}/branches`;
|
||||
debugLog(`Running: gh api ${apiEndpoint} --paginate --jq '.[].name'`);
|
||||
const output = execFileSync(
|
||||
'gh',
|
||||
['api', apiEndpoint, '--paginate', '--jq', '.[].name'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
}
|
||||
);
|
||||
|
||||
const branches = output.trim().split('\n').filter(b => b.length > 0);
|
||||
debugLog('Found branches:', branches.length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: branches
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to get branches:', error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get branches'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all GitHub OAuth handlers
|
||||
*/
|
||||
@@ -307,5 +419,7 @@ export function registerGithubOAuthHandlers(): void {
|
||||
registerGetGhToken();
|
||||
registerGetGhUser();
|
||||
registerListUserRepos();
|
||||
registerDetectGitHubRepo();
|
||||
registerGetGitHubBranches();
|
||||
debugLog('GitHub OAuth handlers registered');
|
||||
}
|
||||
|
||||
@@ -51,6 +51,58 @@ function slugifyTitle(title: string): string {
|
||||
.substring(0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine task category based on GitHub issue labels
|
||||
* Maps to TaskCategory type from shared/types/task.ts
|
||||
*/
|
||||
function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | 'refactoring' | 'documentation' | 'security' | 'performance' | 'ui_ux' | 'infrastructure' | 'testing' {
|
||||
const lowerLabels = labels.map(l => l.toLowerCase());
|
||||
|
||||
// Check for bug labels
|
||||
if (lowerLabels.some(l => l.includes('bug') || l.includes('defect') || l.includes('error') || l.includes('fix'))) {
|
||||
return 'bug_fix';
|
||||
}
|
||||
|
||||
// Check for security labels
|
||||
if (lowerLabels.some(l => l.includes('security') || l.includes('vulnerability') || l.includes('cve'))) {
|
||||
return 'security';
|
||||
}
|
||||
|
||||
// Check for performance labels
|
||||
if (lowerLabels.some(l => l.includes('performance') || l.includes('optimization') || l.includes('speed'))) {
|
||||
return 'performance';
|
||||
}
|
||||
|
||||
// Check for UI/UX labels
|
||||
if (lowerLabels.some(l => l.includes('ui') || l.includes('ux') || l.includes('design') || l.includes('styling'))) {
|
||||
return 'ui_ux';
|
||||
}
|
||||
|
||||
// Check for infrastructure labels
|
||||
if (lowerLabels.some(l => l.includes('infrastructure') || l.includes('devops') || l.includes('deployment') || l.includes('ci') || l.includes('cd'))) {
|
||||
return 'infrastructure';
|
||||
}
|
||||
|
||||
// Check for testing labels
|
||||
if (lowerLabels.some(l => l.includes('test') || l.includes('testing') || l.includes('qa'))) {
|
||||
return 'testing';
|
||||
}
|
||||
|
||||
// Check for refactoring labels
|
||||
if (lowerLabels.some(l => l.includes('refactor') || l.includes('cleanup') || l.includes('maintenance') || l.includes('chore') || l.includes('tech-debt') || l.includes('technical debt'))) {
|
||||
return 'refactoring';
|
||||
}
|
||||
|
||||
// Check for documentation labels
|
||||
if (lowerLabels.some(l => l.includes('documentation') || l.includes('docs'))) {
|
||||
return 'documentation';
|
||||
}
|
||||
|
||||
// Check for enhancement/feature labels (default)
|
||||
// This catches 'enhancement', 'feature', 'improvement', or any unlabeled issues
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new spec directory and initial files
|
||||
*/
|
||||
@@ -59,7 +111,8 @@ export function createSpecForIssue(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
taskDescription: string,
|
||||
githubUrl: string
|
||||
githubUrl: string,
|
||||
labels: string[] = []
|
||||
): SpecCreationData {
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
@@ -104,12 +157,15 @@ export function createSpecForIssue(
|
||||
JSON.stringify(requirements, null, 2)
|
||||
);
|
||||
|
||||
// Determine category from GitHub issue labels
|
||||
const category = determineCategoryFromLabels(labels);
|
||||
|
||||
// task_metadata.json
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'github',
|
||||
githubIssueNumber: issueNumber,
|
||||
githubUrl,
|
||||
category: 'feature'
|
||||
category
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
|
||||
@@ -38,8 +38,11 @@ export interface GitHubAPIRepository {
|
||||
}
|
||||
|
||||
export interface GitHubAPIComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string };
|
||||
user: { login: string; avatar_url?: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReleaseOptions {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function githubFetch(
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
...options.headers
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, IdeationConfig, IdeationGenerationStatus } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import type { AgentManager } from '../../agent';
|
||||
import { debugLog } from '../../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Start ideation generation for a project
|
||||
@@ -18,10 +19,17 @@ export function startIdeationGeneration(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): void {
|
||||
debugLog('[Ideation Handler] Start generation request:', {
|
||||
projectId,
|
||||
enabledTypes: config.enabledTypes,
|
||||
maxIdeasPerType: config.maxIdeasPerType
|
||||
});
|
||||
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
debugLog('[Ideation Handler] Project not found:', projectId);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.IDEATION_ERROR,
|
||||
projectId,
|
||||
@@ -30,6 +38,11 @@ export function startIdeationGeneration(
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('[Ideation Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path
|
||||
});
|
||||
|
||||
// Start ideation generation via agent manager
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, false);
|
||||
|
||||
@@ -91,9 +104,14 @@ export async function stopIdeationGeneration(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): Promise<IPCResult> {
|
||||
debugLog('[Ideation Handler] Stop generation request:', { projectId });
|
||||
|
||||
const wasStopped = agentManager.stopIdeation(projectId);
|
||||
|
||||
debugLog('[Ideation Handler] Stop result:', { projectId, wasStopped });
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
debugLog('[Ideation Handler] Sending stopped event to renderer');
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, Task, TaskMetadata } from '../../shared/types';
|
||||
import type { IPCResult, InsightsSession, InsightsSessionSummary, InsightsModelConfig, Task, TaskMetadata } from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import { insightsService } from '../insights-service';
|
||||
|
||||
@@ -32,7 +32,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.INSIGHTS_SEND_MESSAGE,
|
||||
async (_, projectId: string, message: string) => {
|
||||
async (_, projectId: string, message: string, modelConfig?: InsightsModelConfig) => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
const mainWindow = getMainWindow();
|
||||
@@ -44,7 +44,7 @@ export function registerInsightsHandlers(
|
||||
|
||||
// Note: Python environment initialization should be handled by insightsService
|
||||
// or added here with proper dependency injection if needed
|
||||
insightsService.sendMessage(projectId, project.path, message);
|
||||
insightsService.sendMessage(projectId, project.path, message, modelConfig);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -241,4 +241,57 @@ export function registerInsightsHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Update model configuration for a session
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG,
|
||||
async (_, projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const success = insightsService.updateSessionModelConfig(project.path, sessionId, modelConfig);
|
||||
if (success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Failed to update model configuration' };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Insights Event Forwarding (Service -> Renderer)
|
||||
// ============================================
|
||||
|
||||
// Forward streaming chunks to renderer
|
||||
insightsService.on('stream-chunk', (projectId: string, chunk: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, projectId, chunk);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward status updates to renderer
|
||||
insightsService.on('status', (projectId: string, status: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_STATUS, projectId, status);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward errors to renderer
|
||||
insightsService.on('error', (projectId: string, error: string) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward SDK rate limit events to renderer
|
||||
insightsService.on('sdk-rate-limit', (rateLimitInfo: unknown) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function registerLinearHandlers(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': apiKey
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
||||
import { projectStore } from '../project-store';
|
||||
import { AgentManager } from '../agent';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
|
||||
/**
|
||||
@@ -159,14 +160,30 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get roadmap generation status - allows frontend to query if generation is running
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_GET_STATUS,
|
||||
async (_, projectId: string): Promise<IPCResult<{ isRunning: boolean }>> => {
|
||||
const isRunning = agentManager.isRoadmapRunning(projectId);
|
||||
debugLog('[Roadmap Handler] Get status:', { projectId, isRunning });
|
||||
return { success: true, data: { isRunning } };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
debugLog('[Roadmap Handler] Generate request:', {
|
||||
projectId,
|
||||
enableCompetitorAnalysis
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
debugError('[Roadmap Handler] Project not found:', projectId);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.ROADMAP_ERROR,
|
||||
projectId,
|
||||
@@ -175,6 +192,11 @@ export function registerRoadmapHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('[Roadmap Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path
|
||||
});
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false, enableCompetitorAnalysis ?? false);
|
||||
|
||||
@@ -223,6 +245,27 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.ROADMAP_STOP,
|
||||
async (_, projectId: string): Promise<IPCResult> => {
|
||||
debugLog('[Roadmap Handler] Stop generation request:', { projectId });
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
// Stop roadmap generation for this project
|
||||
const wasStopped = agentManager.stopRoadmap(projectId);
|
||||
|
||||
debugLog('[Roadmap Handler] Stop result:', { projectId, wasStopped });
|
||||
|
||||
if (wasStopped && mainWindow) {
|
||||
debugLog('[Roadmap Handler] Sending stopped event to renderer');
|
||||
mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_STOPPED, projectId);
|
||||
}
|
||||
|
||||
return { success: wasStopped };
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Roadmap Save (full state persistence for drag-and-drop)
|
||||
// ============================================
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentManager } from '../../agent';
|
||||
import { fileWatcher } from '../../file-watcher';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { checkGitStatus } from '../../project-initializer';
|
||||
import { getClaudeProfileManager } from '../../claude-profile-manager';
|
||||
|
||||
/**
|
||||
* Register task execution handlers (start, stop, review, status management, recovery)
|
||||
@@ -62,6 +63,18 @@ export function registerTaskExecutionHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication - Claude requires valid auth to run tasks
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_START] No valid authentication for active profile');
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -265,6 +278,37 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition - 'human_review' requires actual work to have been done
|
||||
// This prevents tasks from being incorrectly marked as ready for review when execution failed
|
||||
if (status === 'human_review') {
|
||||
const specsBaseDirForValidation = getSpecsDir(project.autoBuildPath);
|
||||
const specDirForValidation = path.join(
|
||||
project.path,
|
||||
specsBaseDirForValidation,
|
||||
task.specId
|
||||
);
|
||||
const specFilePath = path.join(specDirForValidation, AUTO_BUILD_PATHS.SPEC_FILE);
|
||||
|
||||
// Check if spec.md exists and has meaningful content (at least 100 chars)
|
||||
const MIN_SPEC_CONTENT_LENGTH = 100;
|
||||
let specContent = '';
|
||||
try {
|
||||
if (existsSync(specFilePath)) {
|
||||
specContent = readFileSync(specFilePath, 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// Ignore read errors - treat as empty spec
|
||||
}
|
||||
|
||||
if (!specContent || specContent.length < MIN_SPEC_CONTENT_LENGTH) {
|
||||
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'human_review' for task ${taskId}. No spec has been created yet.`);
|
||||
return {
|
||||
success: false,
|
||||
error: "Cannot move to human review - no spec has been created yet. The task must complete processing before review."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get the spec directory
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = path.join(
|
||||
@@ -334,6 +378,20 @@ export function registerTaskExecutionHandlers(
|
||||
return { success: false, error: gitStatusCheck.error || 'Git repository required' };
|
||||
}
|
||||
|
||||
// Check authentication before auto-starting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[TASK_UPDATE_STATUS] No valid authentication for active profile');
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.TASK_ERROR,
|
||||
taskId,
|
||||
'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account, or set an OAuth token.'
|
||||
);
|
||||
}
|
||||
return { success: false, error: 'Claude authentication required' };
|
||||
}
|
||||
|
||||
console.warn('[TASK_UPDATE_STATUS] Auto-starting task:', taskId);
|
||||
|
||||
// Start file watcher for this task
|
||||
@@ -562,6 +620,23 @@ export function registerTaskExecutionHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
// Check authentication before auto-restarting
|
||||
const profileManager = getClaudeProfileManager();
|
||||
if (!profileManager.hasValidAuth()) {
|
||||
console.warn('[Recovery] Auth check failed, cannot auto-restart task');
|
||||
// Recovery succeeded but we can't restart without auth
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
taskId,
|
||||
recovered: true,
|
||||
newStatus,
|
||||
message: 'Task recovered but cannot restart: Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.',
|
||||
autoRestarted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Set status to in_progress for the restart
|
||||
newStatus = 'in_progress';
|
||||
|
||||
@@ -402,12 +402,75 @@ export function registerWorktreeHandlers(
|
||||
if (code === 0) {
|
||||
const isStageOnly = options?.noCommit === true;
|
||||
|
||||
// For stage-only: keep in human_review so user commits manually
|
||||
// For full merge: mark as done
|
||||
const newStatus = isStageOnly ? 'human_review' : 'done';
|
||||
const planStatus = isStageOnly ? 'review' : 'completed';
|
||||
// Verify changes were actually staged when stage-only mode is requested
|
||||
// This prevents false positives when merge was already committed previously
|
||||
let hasActualStagedChanges = false;
|
||||
let mergeAlreadyCommitted = false;
|
||||
|
||||
debug('Merge successful. isStageOnly:', isStageOnly, 'newStatus:', newStatus);
|
||||
if (isStageOnly) {
|
||||
try {
|
||||
const gitDiffStaged = execSync('git diff --staged --stat', { cwd: project.path, encoding: 'utf-8' });
|
||||
hasActualStagedChanges = gitDiffStaged.trim().length > 0;
|
||||
debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges);
|
||||
|
||||
if (!hasActualStagedChanges) {
|
||||
// Check if worktree branch was already merged (merge commit exists)
|
||||
const specBranch = `auto-claude/${task.specId}`;
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
const mergeBaseResult = execSync(
|
||||
`git merge-base --is-ancestor ${specBranch} HEAD 2>/dev/null && echo "merged" || echo "not-merged"`,
|
||||
{ cwd: project.path, encoding: 'utf-8' }
|
||||
).trim();
|
||||
mergeAlreadyCommitted = mergeBaseResult === 'merged';
|
||||
debug('Merge already committed check:', mergeAlreadyCommitted);
|
||||
} catch {
|
||||
// Branch may not exist or other error - assume not merged
|
||||
debug('Could not check merge status, assuming not merged');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to verify staged changes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine actual status based on verification
|
||||
let newStatus: string;
|
||||
let planStatus: string;
|
||||
let message: string;
|
||||
let staged: boolean;
|
||||
|
||||
if (isStageOnly && !hasActualStagedChanges && mergeAlreadyCommitted) {
|
||||
// Stage-only was requested but merge was already committed previously
|
||||
// Mark as done since changes are already in the branch
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes were already merged and committed. Task marked as done.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but merge already committed. Marking as done.');
|
||||
} else if (isStageOnly && !hasActualStagedChanges) {
|
||||
// Stage-only was requested but no changes to stage (and not committed)
|
||||
// This could mean nothing to merge or an error - keep in human_review for investigation
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'No changes to stage. The worktree may have no differences from the current branch.';
|
||||
staged = false;
|
||||
debug('Stage-only requested but no changes to stage.');
|
||||
} else if (isStageOnly) {
|
||||
// Stage-only with actual staged changes - expected success case
|
||||
newStatus = 'human_review';
|
||||
planStatus = 'review';
|
||||
message = 'Changes staged in main project. Review with git status and commit when ready.';
|
||||
staged = true;
|
||||
} else {
|
||||
// Full merge (not stage-only)
|
||||
newStatus = 'done';
|
||||
planStatus = 'completed';
|
||||
message = 'Changes merged successfully';
|
||||
staged = false;
|
||||
}
|
||||
|
||||
debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
|
||||
|
||||
// Persist the status change to implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
@@ -419,7 +482,7 @@ export function registerWorktreeHandlers(
|
||||
plan.status = newStatus;
|
||||
plan.planStatus = planStatus;
|
||||
plan.updated_at = new Date().toISOString();
|
||||
if (isStageOnly) {
|
||||
if (staged) {
|
||||
plan.stagedAt = new Date().toISOString();
|
||||
plan.stagedInMainProject = true;
|
||||
}
|
||||
@@ -434,17 +497,13 @@ export function registerWorktreeHandlers(
|
||||
mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, newStatus);
|
||||
}
|
||||
|
||||
const message = isStageOnly
|
||||
? 'Changes staged in main project. Review with git status and commit when ready.'
|
||||
: 'Changes merged successfully';
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message,
|
||||
staged: isStageOnly,
|
||||
projectPath: isStageOnly ? project.path : undefined
|
||||
staged,
|
||||
projectPath: staged ? project.path : undefined
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -533,15 +592,17 @@ export function registerWorktreeHandlers(
|
||||
const gitStatus = execSync('git status --porcelain', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
});
|
||||
|
||||
if (gitStatus) {
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
// Parse the status output to get file names
|
||||
uncommittedFiles = gitStatus.split('\n')
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.substring(3).trim()); // Remove status prefix (e.g., "M ", " M ", "?? ")
|
||||
.map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace
|
||||
|
||||
hasUncommittedChanges = uncommittedFiles.length > 0;
|
||||
console.warn('[IPC] Uncommitted changes detected:', uncommittedFiles.length, 'files');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPC] Failed to check git status:', e);
|
||||
|
||||
@@ -294,7 +294,7 @@ export class ProjectStore {
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
specId: dir.name,
|
||||
projectId,
|
||||
title: plan?.feature || dir.name,
|
||||
title: plan?.feature || plan?.title || dir.name,
|
||||
description,
|
||||
status,
|
||||
reviewReason,
|
||||
|
||||
@@ -22,6 +22,26 @@ const RATE_LIMIT_INDICATORS = [
|
||||
/too\s*many\s*requests/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate authentication failures
|
||||
* These patterns detect when Claude CLI/SDK fails due to missing or invalid auth
|
||||
*/
|
||||
const AUTH_FAILURE_PATTERNS = [
|
||||
/authentication\s*(is\s*)?required/i,
|
||||
/not\s*(yet\s*)?authenticated/i,
|
||||
/login\s*(is\s*)?required/i,
|
||||
/oauth\s*token\s*(is\s*)?(invalid|expired|missing)/i,
|
||||
/unauthorized/i,
|
||||
/please\s*(log\s*in|login|authenticate)/i,
|
||||
/invalid\s*(credentials|token|api\s*key)/i,
|
||||
/auth(entication)?\s*(failed|error|failure)/i,
|
||||
/session\s*(expired|invalid)/i,
|
||||
/access\s*denied/i,
|
||||
/permission\s*denied/i,
|
||||
/401\s*unauthorized/i,
|
||||
/credentials\s*(are\s*)?(missing|invalid|expired)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -43,6 +63,22 @@ export interface RateLimitDetectionResult {
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of authentication failure detection
|
||||
*/
|
||||
export interface AuthFailureDetectionResult {
|
||||
/** Whether an authentication failure was detected */
|
||||
isAuthFailure: boolean;
|
||||
/** The profile ID that failed to authenticate (if known) */
|
||||
profileId?: string;
|
||||
/** The type of auth failure detected */
|
||||
failureType?: 'missing' | 'invalid' | 'expired' | 'unknown';
|
||||
/** User-friendly message describing the failure */
|
||||
message?: string;
|
||||
/** Original error message from the process output */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify rate limit type based on reset time string
|
||||
*/
|
||||
@@ -132,6 +168,80 @@ export function extractResetTime(output: string): string | null {
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the type of authentication failure based on the error message
|
||||
*/
|
||||
function classifyAuthFailureType(output: string): 'missing' | 'invalid' | 'expired' | 'unknown' {
|
||||
const lowerOutput = output.toLowerCase();
|
||||
|
||||
if (/missing|not\s*(yet\s*)?authenticated|required/.test(lowerOutput)) {
|
||||
return 'missing';
|
||||
}
|
||||
if (/expired|session\s*expired/.test(lowerOutput)) {
|
||||
return 'expired';
|
||||
}
|
||||
if (/invalid|unauthorized|denied/.test(lowerOutput)) {
|
||||
return 'invalid';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for the authentication failure
|
||||
*/
|
||||
function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' | 'unknown'): string {
|
||||
switch (failureType) {
|
||||
case 'missing':
|
||||
return 'Claude authentication required. Please go to Settings > Claude Profiles and authenticate your account.';
|
||||
case 'expired':
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect authentication failure from output (stdout + stderr combined)
|
||||
*/
|
||||
export function detectAuthFailure(
|
||||
output: string,
|
||||
profileId?: string
|
||||
): AuthFailureDetectionResult {
|
||||
// First, make sure this isn't a rate limit error (those should be handled separately)
|
||||
if (detectRateLimit(output).isRateLimited) {
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
// Check for authentication failure patterns
|
||||
for (const pattern of AUTH_FAILURE_PATTERNS) {
|
||||
if (pattern.test(output)) {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
|
||||
const failureType = classifyAuthFailureType(output);
|
||||
|
||||
return {
|
||||
isAuthFailure: true,
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isAuthFailure: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains authentication failure error
|
||||
*/
|
||||
export function isAuthFailureError(output: string): boolean {
|
||||
return detectAuthFailure(output).isAuthFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for a specific Claude profile.
|
||||
* Uses OAuth token (CLAUDE_CODE_OAUTH_TOKEN) if available, otherwise falls back to CLAUDE_CONFIG_DIR.
|
||||
|
||||
@@ -32,6 +32,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
*/
|
||||
loadLogsFromPath(specDir: string): TaskLogs | null {
|
||||
const logFile = path.join(specDir, 'task_logs.json');
|
||||
@@ -45,6 +46,13 @@ export class TaskLogService extends EventEmitter {
|
||||
const logs = JSON.parse(content) as TaskLogs;
|
||||
return logs;
|
||||
} catch (error) {
|
||||
// JSON parse error - file may be mid-write, return cached version if available
|
||||
const cached = this.logCache.get(specDir);
|
||||
if (cached) {
|
||||
// Silently return cached version - this is expected during concurrent access
|
||||
return cached;
|
||||
}
|
||||
// Only log if we have no cached fallback
|
||||
console.error(`[TaskLogService] Failed to load logs from ${logFile}:`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import { TIMEOUTS } from './config';
|
||||
*/
|
||||
export function fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
}, (response) => {
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/vnd.github+json'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
@@ -27,7 +27,19 @@ export function fetchJson<T>(url: string): Promise<T> {
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
// Collect response body for error details (limit to 10KB)
|
||||
const maxErrorSize = 10 * 1024;
|
||||
let errorData = '';
|
||||
response.on('data', chunk => {
|
||||
if (errorData.length < maxErrorSize) {
|
||||
errorData += chunk.toString().slice(0, maxErrorSize - errorData.length);
|
||||
}
|
||||
});
|
||||
response.on('end', () => {
|
||||
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
response.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,12 +74,12 @@ export function downloadFile(
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(destPath);
|
||||
|
||||
const request = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/octet-stream'
|
||||
}
|
||||
}, (response) => {
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/octet-stream'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
file.close();
|
||||
@@ -80,7 +92,19 @@ export function downloadFile(
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
reject(new Error(`HTTP ${response.statusCode}`));
|
||||
// Collect response body for error details (limit to 10KB)
|
||||
const maxErrorSize = 10 * 1024;
|
||||
let errorData = '';
|
||||
response.on('data', chunk => {
|
||||
if (errorData.length < maxErrorSize) {
|
||||
errorData += chunk.toString().slice(0, maxErrorSize - errorData.length);
|
||||
}
|
||||
});
|
||||
response.on('end', () => {
|
||||
const errorMsg = `HTTP ${response.statusCode}: ${errorData || response.statusMessage || 'No error details'}`;
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
response.on('error', reject);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SettingsAPI, createSettingsAPI } from './settings-api';
|
||||
import { FileAPI, createFileAPI } from './file-api';
|
||||
import { AgentAPI, createAgentAPI } from './agent-api';
|
||||
import { IdeationAPI, createIdeationAPI } from './modules/ideation-api';
|
||||
import { InsightsAPI, createInsightsAPI } from './modules/insights-api';
|
||||
import { AppUpdateAPI, createAppUpdateAPI } from './app-update-api';
|
||||
|
||||
export interface ElectronAPI extends
|
||||
@@ -15,6 +16,7 @@ export interface ElectronAPI extends
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI {}
|
||||
|
||||
export const createElectronAPI = (): ElectronAPI => ({
|
||||
@@ -25,6 +27,7 @@ export const createElectronAPI = (): ElectronAPI => ({
|
||||
...createFileAPI(),
|
||||
...createAgentAPI(),
|
||||
...createIdeationAPI(),
|
||||
...createInsightsAPI(),
|
||||
...createAppUpdateAPI()
|
||||
});
|
||||
|
||||
@@ -37,6 +40,7 @@ export {
|
||||
createFileAPI,
|
||||
createAgentAPI,
|
||||
createIdeationAPI,
|
||||
createInsightsAPI,
|
||||
createAppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -48,5 +52,6 @@ export type {
|
||||
FileAPI,
|
||||
AgentAPI,
|
||||
IdeationAPI,
|
||||
InsightsAPI,
|
||||
AppUpdateAPI
|
||||
};
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface GitHubAPI {
|
||||
getGitHubRepositories: (projectId: string) => Promise<IPCResult<GitHubRepository[]>>;
|
||||
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
|
||||
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
|
||||
getIssueComments: (projectId: string, issueNumber: number) => Promise<IPCResult<any[]>>;
|
||||
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number) => void;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void;
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise<IPCResult<GitHubImportResult>>;
|
||||
createGitHubRelease: (
|
||||
projectId: string,
|
||||
@@ -40,6 +41,10 @@ export interface GitHubAPI {
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
@@ -66,11 +71,14 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
getGitHubIssue: (projectId: string, issueNumber: number): Promise<IPCResult<GitHubIssue>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber),
|
||||
|
||||
getIssueComments: (projectId: string, issueNumber: number): Promise<IPCResult<any[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_ISSUE_COMMENTS, projectId, issueNumber),
|
||||
|
||||
checkGitHubConnection: (projectId: string): Promise<IPCResult<GitHubSyncStatus>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId),
|
||||
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number): void =>
|
||||
sendIpc(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber),
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]): void =>
|
||||
sendIpc(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber, selectedCommentIds),
|
||||
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]): Promise<IPCResult<GitHubImportResult>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_IMPORT_ISSUES, projectId, issueNumbers),
|
||||
@@ -105,6 +113,13 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listGitHubUserRepos: (): Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_LIST_USER_REPOS),
|
||||
|
||||
// Repository detection
|
||||
detectGitHubRepo: (projectPath: string): Promise<IPCResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath),
|
||||
|
||||
getGitHubBranches: (repo: string, token: string): Promise<IPCResult<string[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_GET_BRANCHES, repo, token),
|
||||
|
||||
// Event Listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
callback: (projectId: string, status: GitHubInvestigationStatus) => void
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsModelConfig,
|
||||
Task,
|
||||
TaskMetadata,
|
||||
IPCResult
|
||||
@@ -16,7 +17,7 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface InsightsAPI {
|
||||
// Operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
@@ -29,6 +30,7 @@ export interface InsightsAPI {
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise<IPCResult>;
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
@@ -50,8 +52,8 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
getInsightsSession: (projectId: string): Promise<IPCResult<InsightsSession | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId),
|
||||
|
||||
sendInsightsMessage: (projectId: string, message: string): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message),
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig): void =>
|
||||
sendIpc(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message, modelConfig),
|
||||
|
||||
clearInsightsSession: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId),
|
||||
@@ -79,6 +81,9 @@ export const createInsightsAPI = (): InsightsAPI => ({
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle),
|
||||
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.INSIGHTS_UPDATE_MODEL_CONFIG, projectId, sessionId, modelConfig),
|
||||
|
||||
// Event Listeners
|
||||
onInsightsStreamChunk: (
|
||||
callback: (projectId: string, chunk: InsightsStreamChunk) => void
|
||||
|
||||
@@ -14,9 +14,11 @@ import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc
|
||||
export interface RoadmapAPI {
|
||||
// Operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
stopRoadmap: (projectId: string) => Promise<IPCResult>;
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
@@ -37,6 +39,9 @@ export interface RoadmapAPI {
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
) => IpcListenerCleanup;
|
||||
onRoadmapStopped: (
|
||||
callback: (projectId: string) => void
|
||||
) => IpcListenerCleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +52,9 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
getRoadmap: (projectId: string): Promise<IPCResult<Roadmap | null>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET, projectId),
|
||||
|
||||
getRoadmapStatus: (projectId: string): Promise<IPCResult<{ isRunning: boolean }>> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_GET_STATUS, projectId),
|
||||
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_SAVE, projectId, roadmap),
|
||||
|
||||
@@ -56,6 +64,9 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis),
|
||||
|
||||
stopRoadmap: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_STOP, projectId),
|
||||
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
@@ -83,5 +94,10 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback)
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_ERROR, callback),
|
||||
|
||||
onRoadmapStopped: (
|
||||
callback: (projectId: string) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.ROADMAP_STOPPED, callback)
|
||||
});
|
||||
|
||||
@@ -6,3 +6,6 @@ const electronAPI = createElectronAPI();
|
||||
|
||||
// Expose to renderer via contextBridge
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
// Expose debug flag for debug logging
|
||||
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
|
||||
|
||||
@@ -37,6 +37,7 @@ import { OnboardingWizard } from './components/onboarding';
|
||||
import { AppUpdateNotification } from './components/AppUpdateNotification';
|
||||
import { UsageIndicator } from './components/UsageIndicator';
|
||||
import { ProactiveSwapListener } from './components/ProactiveSwapListener';
|
||||
import { GitHubSetupModal } from './components/GitHubSetupModal';
|
||||
import { useProjectStore, loadProjects, addProject, initializeProject } from './stores/project-store';
|
||||
import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings } from './stores/settings-store';
|
||||
@@ -70,6 +71,10 @@ export function App() {
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
|
||||
|
||||
// GitHub setup state (shown after Auto Claude init)
|
||||
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
|
||||
const [gitHubSetupProject, setGitHubSetupProject] = useState<Project | null>(null);
|
||||
|
||||
// Get selected project
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
|
||||
@@ -130,12 +135,15 @@ export function App() {
|
||||
|
||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||
useEffect(() => {
|
||||
// Don't show dialog while initialization is in progress
|
||||
if (isInitializing) return;
|
||||
|
||||
if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) {
|
||||
// Project exists but isn't initialized - show init dialog
|
||||
setPendingProject(selectedProject);
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}, [selectedProject, skippedInitProjectId]);
|
||||
}, [selectedProject, skippedInitProjectId, isInitializing]);
|
||||
|
||||
// Load tasks when project changes
|
||||
useEffect(() => {
|
||||
@@ -235,18 +243,64 @@ export function App() {
|
||||
const handleInitialize = async () => {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
const result = await initializeProject(pendingProject.id);
|
||||
const result = await initializeProject(projectId);
|
||||
if (result?.success) {
|
||||
setShowInitDialog(false);
|
||||
// Get the updated project from store
|
||||
const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId);
|
||||
|
||||
// Clear init dialog state
|
||||
setPendingProject(null);
|
||||
setShowInitDialog(false);
|
||||
|
||||
// Show GitHub setup modal
|
||||
if (updatedProject) {
|
||||
setGitHubSetupProject(updatedProject);
|
||||
setShowGitHubSetup(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSetupComplete = async (settings: {
|
||||
githubToken: string;
|
||||
githubRepo: string;
|
||||
mainBranch: string;
|
||||
}) => {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
try {
|
||||
// Update project env config with GitHub settings
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken,
|
||||
githubRepo: settings.githubRepo
|
||||
});
|
||||
|
||||
// Update project settings with mainBranch
|
||||
await window.electronAPI.updateProjectSettings(gitHubSetupProject.id, {
|
||||
mainBranch: settings.mainBranch
|
||||
});
|
||||
|
||||
// Refresh projects to get updated data
|
||||
await loadProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to save GitHub settings:', error);
|
||||
}
|
||||
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleGitHubSetupSkip = () => {
|
||||
setShowGitHubSetup(false);
|
||||
setGitHubSetupProject(null);
|
||||
};
|
||||
|
||||
const handleSkipInit = () => {
|
||||
if (pendingProject) {
|
||||
setSkippedInitProjectId(pendingProject.id);
|
||||
@@ -258,8 +312,8 @@ export function App() {
|
||||
const handleGoToTask = (taskId: string) => {
|
||||
// Switch to kanban view
|
||||
setActiveView('kanban');
|
||||
// Find and select the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
// Find and select the task (match by id or specId)
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
if (task) {
|
||||
setSelectedTask(task);
|
||||
}
|
||||
@@ -340,10 +394,13 @@ export function App() {
|
||||
<Insights projectId={selectedProjectId} />
|
||||
)}
|
||||
{activeView === 'github-issues' && selectedProjectId && (
|
||||
<GitHubIssues onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}} />
|
||||
<GitHubIssues
|
||||
onOpenSettings={() => {
|
||||
setSettingsInitialProjectSection('github');
|
||||
setIsSettingsDialogOpen(true);
|
||||
}}
|
||||
onNavigateToTask={handleGoToTask}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'changelog' && selectedProjectId && (
|
||||
<Changelog />
|
||||
@@ -414,7 +471,9 @@ export function App() {
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Only trigger skip if user manually closed the dialog
|
||||
// Don't trigger if pendingProject is null (successful init) or if initializing
|
||||
if (!open && pendingProject && !isInitializing) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
@@ -475,6 +534,17 @@ export function App() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* GitHub Setup Modal - shows after Auto Claude init to configure GitHub */}
|
||||
{gitHubSetupProject && (
|
||||
<GitHubSetupModal
|
||||
open={showGitHubSetup}
|
||||
onOpenChange={setShowGitHubSetup}
|
||||
project={gitHubSetupProject}
|
||||
onComplete={handleGitHubSetupComplete}
|
||||
onSkip={handleGitHubSetupSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rate Limit Modal - shows when Claude Code hits usage limits (terminal) */}
|
||||
<RateLimitModal />
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* AgentProfileSelector - Reusable component for selecting agent profile in forms
|
||||
*
|
||||
* Provides a dropdown for quick profile selection (Auto, Complex, Balanced, Quick)
|
||||
* with an inline "Custom" option that reveals model and thinking level selects.
|
||||
* The "Auto" profile shows per-phase model configuration.
|
||||
*
|
||||
* Used in TaskCreationWizard and TaskEditDialog.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface AgentProfileSelectorProps {
|
||||
/** Currently selected profile ID ('auto', 'complex', 'balanced', 'quick', or 'custom') */
|
||||
profileId: string;
|
||||
/** Current model value (fallback for non-auto profiles) */
|
||||
model: ModelType | '';
|
||||
/** Current thinking level value (fallback for non-auto profiles) */
|
||||
thinkingLevel: ThinkingLevel | '';
|
||||
/** Phase model configuration (for auto profile) */
|
||||
phaseModels?: PhaseModelConfig;
|
||||
/** Phase thinking configuration (for auto profile) */
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
/** Called when profile selection changes */
|
||||
onProfileChange: (profileId: string, model: ModelType, thinkingLevel: ThinkingLevel) => void;
|
||||
/** Called when model changes (in custom mode) */
|
||||
onModelChange: (model: ModelType) => void;
|
||||
/** Called when thinking level changes (in custom mode) */
|
||||
onThinkingLevelChange: (level: ThinkingLevel) => void;
|
||||
/** Called when phase models change (in auto mode) */
|
||||
onPhaseModelsChange?: (phaseModels: PhaseModelConfig) => void;
|
||||
/** Called when phase thinking changes (in auto mode) */
|
||||
onPhaseThinkingChange?: (phaseThinking: PhaseThinkingConfig) => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap,
|
||||
Sparkles
|
||||
};
|
||||
|
||||
const PHASE_LABELS: Record<keyof PhaseModelConfig, { label: string; description: string }> = {
|
||||
spec: { label: 'Spec Creation', description: 'Discovery, requirements, context gathering' },
|
||||
planning: { label: 'Planning', description: 'Implementation planning and architecture' },
|
||||
coding: { label: 'Coding', description: 'Actual code implementation' },
|
||||
qa: { label: 'QA Review', description: 'Quality assurance and validation' }
|
||||
};
|
||||
|
||||
export function AgentProfileSelector({
|
||||
profileId,
|
||||
model,
|
||||
thinkingLevel,
|
||||
phaseModels,
|
||||
phaseThinking,
|
||||
onProfileChange,
|
||||
onModelChange,
|
||||
onThinkingLevelChange,
|
||||
onPhaseModelsChange,
|
||||
onPhaseThinkingChange,
|
||||
disabled
|
||||
}: AgentProfileSelectorProps) {
|
||||
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
||||
|
||||
const isCustom = profileId === 'custom';
|
||||
const isAuto = profileId === 'auto';
|
||||
|
||||
// Use provided phase configs or defaults
|
||||
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
||||
const currentPhaseThinking = phaseThinking || DEFAULT_PHASE_THINKING;
|
||||
|
||||
const handleProfileSelect = (selectedId: string) => {
|
||||
if (selectedId === 'custom') {
|
||||
// Keep current model/thinking level, just mark as custom
|
||||
onProfileChange('custom', model as ModelType || 'sonnet', thinkingLevel as ThinkingLevel || 'medium');
|
||||
} else if (selectedId === 'auto') {
|
||||
// Auto profile - set defaults
|
||||
const autoProfile = DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto');
|
||||
if (autoProfile) {
|
||||
onProfileChange('auto', autoProfile.model, autoProfile.thinkingLevel);
|
||||
// Initialize phase configs with defaults if callback provided
|
||||
if (onPhaseModelsChange && autoProfile.phaseModels) {
|
||||
onPhaseModelsChange(autoProfile.phaseModels);
|
||||
}
|
||||
if (onPhaseThinkingChange && autoProfile.phaseThinking) {
|
||||
onPhaseThinkingChange(autoProfile.phaseThinking);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedId);
|
||||
if (profile) {
|
||||
onProfileChange(profile.id, profile.model, profile.thinkingLevel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseModelChange = (phase: keyof PhaseModelConfig, value: ModelType) => {
|
||||
if (onPhaseModelsChange) {
|
||||
onPhaseModelsChange({
|
||||
...currentPhaseModels,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseThinkingChange = (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
|
||||
if (onPhaseThinkingChange) {
|
||||
onPhaseThinkingChange({
|
||||
...currentPhaseThinking,
|
||||
[phase]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get profile display info
|
||||
const getProfileDisplay = () => {
|
||||
if (isCustom) {
|
||||
return {
|
||||
icon: Sliders,
|
||||
label: 'Custom Configuration',
|
||||
description: 'Choose model & thinking level'
|
||||
};
|
||||
}
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (profile) {
|
||||
return {
|
||||
icon: iconMap[profile.icon || 'Scale'] || Scale,
|
||||
label: profile.name,
|
||||
description: profile.description
|
||||
};
|
||||
}
|
||||
// Default to balanced
|
||||
return {
|
||||
icon: Scale,
|
||||
label: 'Balanced',
|
||||
description: 'Good balance of speed and quality'
|
||||
};
|
||||
};
|
||||
|
||||
const display = getProfileDisplay();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Agent Profile Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-profile" className="text-sm font-medium text-foreground">
|
||||
Agent Profile
|
||||
</Label>
|
||||
<Select
|
||||
value={profileId}
|
||||
onValueChange={handleProfileSelect}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="agent-profile" className="h-10">
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<display.icon className="h-4 w-4" />
|
||||
<span>{display.label}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_AGENT_PROFILES.map((profile) => {
|
||||
const ProfileIcon = iconMap[profile.icon || 'Scale'] || Scale;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === profile.model)?.label;
|
||||
return (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">{profile.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{profile.isAutoProfile
|
||||
? '(per-phase optimization)'
|
||||
: `(${modelLabel} + ${profile.thinkingLevel})`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">Custom</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
(Choose model & thinking level)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{display.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Profile - Phase Configuration */}
|
||||
{isAuto && (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* Phase Summary */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between text-sm',
|
||||
'text-muted-foreground hover:text-foreground transition-colors'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="font-medium text-foreground">Phase Configuration</span>
|
||||
{showPhaseDetails ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Compact summary when collapsed */}
|
||||
{!showPhaseDetails && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentPhaseModels[phase])?.label?.replace('Claude ', '') || currentPhaseModels[phase];
|
||||
return (
|
||||
<div key={phase} className="flex items-center justify-between rounded bg-background/50 px-2 py-1">
|
||||
<span className="text-muted-foreground">{PHASE_LABELS[phase].label}:</span>
|
||||
<span className="font-medium">{modelLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detailed Phase Configuration */}
|
||||
{showPhaseDetails && (
|
||||
<div className="space-y-4 pt-2">
|
||||
{(Object.keys(PHASE_LABELS) as Array<keyof PhaseModelConfig>).map((phase) => (
|
||||
<div key={phase} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{PHASE_LABELS[phase].label}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{PHASE_LABELS[phase].description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={currentPhaseModels[phase]}
|
||||
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={currentPhaseThinking[phase]}
|
||||
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Configuration (shown only when custom is selected) */}
|
||||
{isCustom && (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/30 p-4">
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-model" className="text-xs font-medium text-muted-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => onModelChange(value as ModelType)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-model" className="h-9">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-thinking" className="text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</Label>
|
||||
<Select
|
||||
value={thinkingLevel}
|
||||
onValueChange={(value) => onThinkingLevelChange(value as ThinkingLevel)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="custom-thinking" className="h-9">
|
||||
<SelectValue placeholder="Select thinking level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
- {level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { TrendingUp, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './ui/dialog';
|
||||
import { Badge } from './ui/badge';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import type { CompetitorAnalysis } from '../../shared/types';
|
||||
|
||||
interface CompetitorAnalysisViewerProps {
|
||||
analysis: CompetitorAnalysis | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CompetitorAnalysisViewer({
|
||||
analysis,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CompetitorAnalysisViewerProps) {
|
||||
if (!analysis) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Competitor Analysis Results
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Analyzed {analysis.competitors.length} competitors to identify market gaps and opportunities
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-auto pr-4" style={{ maxHeight: 'calc(85vh - 120px)' }}>
|
||||
<div className="space-y-6 pb-4">
|
||||
{analysis.competitors.map((competitor) => (
|
||||
<div
|
||||
key={competitor.id}
|
||||
className="rounded-lg border border-border p-4 space-y-3"
|
||||
>
|
||||
{/* Competitor Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold">{competitor.name}</h3>
|
||||
{competitor.marketPosition && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{competitor.marketPosition}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{competitor.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{competitor.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{competitor.url && (
|
||||
<a
|
||||
href={competitor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 text-sm ml-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Visit
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pain Points */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-warning" />
|
||||
Identified Pain Points ({competitor.painPoints.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{competitor.painPoints.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No pain points identified
|
||||
</p>
|
||||
) : (
|
||||
competitor.painPoints.map((painPoint) => (
|
||||
<div
|
||||
key={painPoint.id}
|
||||
className="rounded bg-muted/50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
painPoint.severity === 'high'
|
||||
? 'destructive'
|
||||
: painPoint.severity === 'medium'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
className="mt-0.5"
|
||||
>
|
||||
{painPoint.severity}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{painPoint.description}
|
||||
</p>
|
||||
{painPoint.source && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Source: <span className="italic">{painPoint.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{painPoint.frequency && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Frequency: {painPoint.frequency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{painPoint.opportunity && (
|
||||
<div className="mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Opportunity:{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{painPoint.opportunity}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Insights Summary */}
|
||||
{analysis.insightsSummary && (
|
||||
<div className="rounded-lg bg-primary/5 border border-primary/20 p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold">Market Insights Summary</h4>
|
||||
|
||||
{analysis.insightsSummary.topPainPoints.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Top Pain Points:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.topPainPoints.map((point, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.insightsSummary.differentiatorOpportunities.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Differentiator Opportunities:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.differentiatorOpportunities.map((opp, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {opp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.insightsSummary.marketTrends.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">Market Trends:</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{analysis.insightsSummary.marketTrends.map((trend, idx) => (
|
||||
<li key={idx} className="text-muted-foreground">• {trend}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { AVAILABLE_MODELS, THINKING_LEVELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import type { ModelType, ThinkingLevel } from '../../shared/types';
|
||||
|
||||
interface CustomModelModalProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onSave: (config: InsightsModelConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CustomModelModal({ currentConfig, onSave, onClose }: CustomModelModalProps) {
|
||||
const [model, setModel] = useState<ModelType>(
|
||||
currentConfig?.model || 'sonnet'
|
||||
);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
|
||||
currentConfig?.thinkingLevel || 'medium'
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
profileId: 'custom',
|
||||
model,
|
||||
thinkingLevel
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the model and thinking level for this chat session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-select">Model</Label>
|
||||
<Select value={model} onValueChange={(v) => setModel(v as ModelType)}>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thinking-select">Thinking Level</Label>
|
||||
<Select value={thinkingLevel} onValueChange={(v) => setThinkingLevel(v as ThinkingLevel)}>
|
||||
<SelectTrigger id="thinking-select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{level.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level.description}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info
|
||||
Info,
|
||||
LogIn,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -26,6 +29,8 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from './ui/tooltip';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { ClaudeProfile } from '../../shared/types';
|
||||
|
||||
interface EnvConfigModalProps {
|
||||
open: boolean;
|
||||
@@ -33,6 +38,7 @@ interface EnvConfigModalProps {
|
||||
onConfigured?: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function EnvConfigModal({
|
||||
@@ -40,50 +46,168 @@ export function EnvConfigModal({
|
||||
onOpenChange,
|
||||
onConfigured,
|
||||
title = 'Claude Authentication Required',
|
||||
description = 'A Claude Code OAuth token is required to use AI features like Ideation and Roadmap generation.'
|
||||
description = 'A Claude Code OAuth token is required to use AI features like Ideation and Roadmap generation.',
|
||||
projectId
|
||||
}: EnvConfigModalProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [_isLoading, _setIsLoading] = useState(false);
|
||||
const [showManualEntry, setShowManualEntry] = useState(false);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [sourcePath, setSourcePath] = useState<string | null>(null);
|
||||
const [hasExistingToken, setHasExistingToken] = useState(false);
|
||||
const [claudeProfiles, setClaudeProfiles] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
oauthToken?: string;
|
||||
email?: string;
|
||||
isDefault: boolean;
|
||||
}>>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isLoadingProfiles, setIsLoadingProfiles] = useState(true);
|
||||
|
||||
// Check current token status when modal opens
|
||||
// Load Claude profiles and check token status when modal opens
|
||||
useEffect(() => {
|
||||
const checkToken = async () => {
|
||||
const loadData = async () => {
|
||||
if (!open) return;
|
||||
|
||||
setIsChecking(true);
|
||||
setIsLoadingProfiles(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.checkSourceToken();
|
||||
if (result.success && result.data) {
|
||||
setSourcePath(result.data.sourcePath || null);
|
||||
setHasExistingToken(result.data.hasToken);
|
||||
// Load both token status and Claude profiles in parallel
|
||||
const [tokenResult, profilesResult] = await Promise.all([
|
||||
window.electronAPI.checkSourceToken(),
|
||||
window.electronAPI.getClaudeProfiles()
|
||||
]);
|
||||
|
||||
if (result.data.hasToken) {
|
||||
// Handle token status
|
||||
if (tokenResult.success && tokenResult.data) {
|
||||
setSourcePath(tokenResult.data.sourcePath || null);
|
||||
setHasExistingToken(tokenResult.data.hasToken);
|
||||
|
||||
if (tokenResult.data.hasToken) {
|
||||
// Token exists, show success state
|
||||
setSuccess(true);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to check token status');
|
||||
setError(tokenResult.error || 'Failed to check token status');
|
||||
}
|
||||
|
||||
// Handle Claude profiles
|
||||
if (profilesResult.success && profilesResult.data) {
|
||||
const authenticatedProfiles = profilesResult.data.profiles.filter(
|
||||
(p: ClaudeProfile) => p.oauthToken || (p.isDefault && p.configDir)
|
||||
);
|
||||
setClaudeProfiles(authenticatedProfiles);
|
||||
|
||||
// Auto-select first authenticated profile
|
||||
if (authenticatedProfiles.length > 0 && !selectedProfileId) {
|
||||
setSelectedProfileId(authenticatedProfiles[0].id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
setIsLoadingProfiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkToken();
|
||||
loadData();
|
||||
}, [open]);
|
||||
|
||||
// Listen for OAuth token from terminal
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const cleanup = window.electronAPI.onTerminalOAuthToken(async (info) => {
|
||||
if (info.success) {
|
||||
// Token is auto-saved to the profile by the main process
|
||||
// Just update UI state to reflect authentication success
|
||||
setSuccess(true);
|
||||
setHasExistingToken(true);
|
||||
setIsAuthenticating(false);
|
||||
|
||||
// Notify parent
|
||||
setTimeout(() => {
|
||||
onConfigured?.();
|
||||
onOpenChange(false);
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, [open, onConfigured, onOpenChange]);
|
||||
|
||||
const handleUseExistingProfile = async () => {
|
||||
if (!selectedProfileId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the selected profile's token
|
||||
const profile = claudeProfiles.find(p => p.id === selectedProfileId);
|
||||
if (!profile?.oauthToken) {
|
||||
setError('Selected profile does not have a valid token');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the token to auto-claude .env
|
||||
const result = await window.electronAPI.updateSourceEnv({
|
||||
claudeOAuthToken: profile.oauthToken
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setHasExistingToken(true);
|
||||
|
||||
// Notify parent
|
||||
setTimeout(() => {
|
||||
onConfigured?.();
|
||||
onOpenChange(false);
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(result.error || 'Failed to save token');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticateWithBrowser = async () => {
|
||||
if (!projectId) {
|
||||
setError('No project selected. Please select a project first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Invoke the Claude setup-token flow in terminal
|
||||
const result = await window.electronAPI.invokeClaudeSetup(projectId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to start authentication');
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
// Keep isAuthenticating true - will be cleared when token is received
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start authentication');
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!token.trim()) {
|
||||
setError('Please enter a token');
|
||||
@@ -182,89 +306,258 @@ export function EnvConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about getting a token */}
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
How to get a Claude Code OAuth token:
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
<li>Install Claude Code CLI if you haven't already</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
claude setup-token
|
||||
</code>
|
||||
{' '}
|
||||
{/* Option 1: Use existing authenticated profile */}
|
||||
{!isLoadingProfiles && claudeProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground font-medium mb-1">
|
||||
Use Existing Account
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You have {claudeProfiles.length} authenticated Claude account{claudeProfiles.length > 1 ? 's' : ''}. Select one to use:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
Select Account
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
{claudeProfiles.map((profile) => (
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="inline-flex items-center text-info hover:text-info/80"
|
||||
key={profile.id}
|
||||
onClick={() => setSelectedProfileId(profile.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left",
|
||||
selectedProfileId === profile.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<Copy className="h-3 w-3 ml-1" />
|
||||
<div className={cn(
|
||||
"h-4 w-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
selectedProfileId === profile.id
|
||||
? "border-primary"
|
||||
: "border-muted-foreground"
|
||||
)}>
|
||||
{selectedProfileId === profile.id && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{profile.name}
|
||||
{profile.isDefault && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
|
||||
)}
|
||||
</p>
|
||||
{profile.email && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{profile.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CheckCircle2 className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
selectedProfileId === profile.id ? "text-primary" : "text-transparent"
|
||||
)} />
|
||||
</button>
|
||||
</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="text-sm text-info hover:text-info/80 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View documentation
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUseExistingProfile}
|
||||
disabled={!selectedProfileId || isSaving}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
Use This Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token" className="text-sm font-medium text-foreground">
|
||||
Claude Code OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Enter your token..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? 'Hide token' : 'Show token'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Option 2: Authenticate new account with browser */}
|
||||
{!isLoadingProfiles && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground font-medium mb-1">
|
||||
{claudeProfiles.length > 0 ? 'Or Authenticate New Account' : 'Authenticate with Browser'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{claudeProfiles.length > 0
|
||||
? 'Add a new Claude account by logging in with your browser.'
|
||||
: 'Click below to open your browser and log in with your Claude account.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAuthenticateWithBrowser}
|
||||
disabled={isAuthenticating}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant={claudeProfiles.length > 0 ? "outline" : "default"}
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Waiting for authentication...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-5 w-5" />
|
||||
{claudeProfiles.length > 0 ? 'Authenticate New Account' : 'Authenticate with Browser'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isAuthenticating && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
A browser window should open. Complete the authentication there, then return here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The token will be saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Divider before manual entry */}
|
||||
{!isLoadingProfiles && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary: Manual Token Entry (Collapsible) */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowManualEntry(!showManualEntry)}
|
||||
className="w-full flex items-center justify-between text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Enter token manually</span>
|
||||
{showManualEntry ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showManualEntry && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-border">
|
||||
{/* Manual token instructions */}
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">Steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Install Claude Code CLI if you haven't already</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
claude setup-token
|
||||
</code>
|
||||
{' '}
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="inline-flex items-center text-info hover:text-info/80"
|
||||
>
|
||||
<Copy className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="text-info hover:text-info/80 flex items-center gap-1 mt-2"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View documentation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Token input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token" className="text-sm font-medium text-foreground">
|
||||
Claude Code OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Enter your token..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isAuthenticating}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? 'Hide token' : 'Show token'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The token will be saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing token info */}
|
||||
{hasExistingToken && (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A token is already configured. Enter a new token above to replace it.
|
||||
A token is already configured. {showManualEntry ? 'Enter a new token above to replace it.' : 'Authenticate again to replace it.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,11 +565,11 @@ export function EnvConfigModal({
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving || isAuthenticating}>
|
||||
{success ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!success && (
|
||||
<Button onClick={handleSave} disabled={!token.trim() || isSaving}>
|
||||
{!success && showManualEntry && token.trim() && (
|
||||
<Button onClick={handleSave} disabled={isSaving || isAuthenticating}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
import { useGitHubIssues, useGitHubInvestigation, useIssueFiltering } from './github-issues/hooks';
|
||||
import {
|
||||
NotConnectedState,
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
import type { GitHubIssue } from '../../shared/types';
|
||||
import type { GitHubIssuesProps } from './github-issues/types';
|
||||
|
||||
export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesProps) {
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const tasks = useTaskStore((state) => state.tasks);
|
||||
|
||||
const {
|
||||
issues,
|
||||
@@ -43,14 +45,25 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
|
||||
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitHubIssue | null>(null);
|
||||
|
||||
// Build a map of GitHub issue numbers to task IDs for quick lookup
|
||||
const issueToTaskMap = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const task of tasks) {
|
||||
if (task.metadata?.githubIssueNumber) {
|
||||
map.set(task.metadata.githubIssueNumber, task.specId || task.id);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
const handleInvestigate = useCallback((issue: GitHubIssue) => {
|
||||
setSelectedIssueForInvestigation(issue);
|
||||
setShowInvestigateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleStartInvestigation = useCallback(() => {
|
||||
const handleStartInvestigation = useCallback((selectedCommentIds: number[]) => {
|
||||
if (selectedIssueForInvestigation) {
|
||||
startInvestigation(selectedIssueForInvestigation);
|
||||
startInvestigation(selectedIssueForInvestigation, selectedCommentIds);
|
||||
}
|
||||
}, [selectedIssueForInvestigation, startInvestigation]);
|
||||
|
||||
@@ -110,6 +123,8 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
? lastInvestigationResult
|
||||
: null
|
||||
}
|
||||
linkedTaskId={issueToTaskMap.get(selectedIssue.number)}
|
||||
onViewTask={onNavigateToTask}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Select an issue to view details" />
|
||||
@@ -125,6 +140,7 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
|
||||
investigationStatus={investigationStatus}
|
||||
onStartInvestigation={handleStartInvestigation}
|
||||
onClose={handleCloseDialog}
|
||||
projectId={selectedProject?.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Github,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from './ui/dialog';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
|
||||
import type { Project, ProjectSettings } from '../../shared/types';
|
||||
|
||||
interface GitHubSetupModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: Project;
|
||||
onComplete: (settings: { githubToken: string; githubRepo: string; mainBranch: string }) => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'auth' | 'repo' | 'branch' | 'complete';
|
||||
|
||||
/**
|
||||
* GitHub Setup Modal - Required setup flow after Auto Claude initialization
|
||||
*
|
||||
* Flow:
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth)
|
||||
* 2. Detect/confirm repository
|
||||
* 3. Select base branch for tasks (with recommended default)
|
||||
*/
|
||||
export function GitHubSetupModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onComplete,
|
||||
onSkip
|
||||
}: GitHubSetupModalProps) {
|
||||
const [step, setStep] = useState<SetupStep>('auth');
|
||||
const [githubToken, setGithubToken] = useState<string | null>(null);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
const [recommendedBranch, setRecommendedBranch] = useState<string | null>(null);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('auth');
|
||||
setGithubToken(null);
|
||||
setGithubRepo(null);
|
||||
setDetectedRepo(null);
|
||||
setBranches([]);
|
||||
setSelectedBranch(null);
|
||||
setRecommendedBranch(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Detect repository from git remote when auth succeeds
|
||||
const detectRepository = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to detect repo from git remote
|
||||
const result = await window.electronAPI.detectGitHubRepo(project.path);
|
||||
if (result.success && result.data) {
|
||||
setDetectedRepo(result.data);
|
||||
setGithubRepo(result.data);
|
||||
setStep('branch');
|
||||
// Immediately load branches
|
||||
await loadBranches(result.data);
|
||||
} else {
|
||||
// No remote detected, show repo input step
|
||||
setStep('repo');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect repository');
|
||||
setStep('repo');
|
||||
} finally {
|
||||
setIsLoadingRepo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load branches from GitHub
|
||||
const loadBranches = async (repo: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get branches from GitHub API
|
||||
const result = await window.electronAPI.getGitHubBranches(repo, githubToken!);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
|
||||
// Detect recommended branch (main > master > develop > first)
|
||||
const recommended = detectRecommendedBranch(result.data);
|
||||
setRecommendedBranch(recommended);
|
||||
setSelectedBranch(recommended);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load branches');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load branches');
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect recommended branch from list
|
||||
const detectRecommendedBranch = (branchList: string[]): string | null => {
|
||||
const priorities = ['main', 'master', 'develop', 'dev'];
|
||||
for (const priority of priorities) {
|
||||
if (branchList.includes(priority)) {
|
||||
return priority;
|
||||
}
|
||||
}
|
||||
return branchList[0] || null;
|
||||
};
|
||||
|
||||
// Handle OAuth success
|
||||
const handleAuthSuccess = async (token: string) => {
|
||||
setGithubToken(token);
|
||||
// Move to repo detection
|
||||
await detectRepository();
|
||||
};
|
||||
|
||||
// Handle branch selection complete
|
||||
const handleComplete = () => {
|
||||
if (githubToken && githubRepo && selectedBranch) {
|
||||
onComplete({
|
||||
githubToken,
|
||||
githubRepo,
|
||||
mainBranch: selectedBranch
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Connect to GitHub
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude requires GitHub to manage your code branches and keep tasks up to date.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<GitHubOAuthFlow
|
||||
onSuccess={handleAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'repo':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
Repository Not Detected
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
We couldn't detect a GitHub repository for this project. Please ensure your project has a GitHub remote configured.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">No GitHub remote found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
To use Auto Claude, your project needs to be connected to a GitHub repository.
|
||||
</p>
|
||||
<div className="text-xs font-mono bg-muted p-2 rounded mt-2">
|
||||
git remote add origin https://github.com/owner/repo.git
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={detectRepository} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Retry Detection'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'branch':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
Select Base Branch
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which branch Auto Claude should use as the base for creating task branches.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* Show detected repo */}
|
||||
{detectedRepo && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Repository:</span>
|
||||
<code className="px-2 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
{detectedRepo}
|
||||
</code>
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Base Branch</Label>
|
||||
<Select
|
||||
value={selectedBranch || ''}
|
||||
onValueChange={setSelectedBranch}
|
||||
disabled={isLoadingBranches || branches.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{isLoadingBranches ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Loading branches...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{branch}</span>
|
||||
{branch === recommendedBranch && (
|
||||
<span className="flex items-center gap-1 text-xs text-success">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All tasks will be created from branches like{' '}
|
||||
<code className="px-1 bg-muted rounded">auto-claude/task-name</code>
|
||||
{selectedBranch && (
|
||||
<> based on <code className="px-1 bg-muted rounded">{selectedBranch}</code></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info about branch selection */}
|
||||
<div className="rounded-lg border border-info/30 bg-info/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-info mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Why select a branch?</p>
|
||||
<p className="mt-1">
|
||||
Auto Claude creates isolated workspaces for each task. Selecting the right base branch ensures
|
||||
your tasks start with the latest code from your main development line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onSkip && (
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!selectedBranch || isLoadingBranches}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Complete Setup
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
Setup Complete
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-8 flex flex-col items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Auto Claude is ready to use! You can now create tasks that will be
|
||||
automatically based on <code className="px-1 bg-muted rounded">{selectedBranch}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Progress indicator
|
||||
const renderProgress = () => {
|
||||
const steps: { key: SetupStep; label: string }[] = [
|
||||
{ key: 'auth', label: 'Connect' },
|
||||
{ key: 'branch', label: 'Configure' },
|
||||
];
|
||||
|
||||
// Don't show progress on complete step
|
||||
if (step === 'complete') return null;
|
||||
|
||||
const currentIndex = step === 'auth' ? 0 : step === 'repo' ? 0 : 1;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{steps.map((s, index) => (
|
||||
<div key={s.key} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
index < currentIndex
|
||||
? 'bg-success text-success-foreground'
|
||||
: index === currentIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span className={`ml-2 text-xs ${
|
||||
index === currentIndex ? 'text-foreground font-medium' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
{index < steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{renderProgress()}
|
||||
{renderStepContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,14 @@ import {
|
||||
switchSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateModelConfig,
|
||||
createTaskFromSuggestion,
|
||||
setupInsightsListeners
|
||||
} from '../stores/insights-store';
|
||||
import { loadTasks } from '../stores/task-store';
|
||||
import { ChatHistorySidebar } from './ChatHistorySidebar';
|
||||
import type { InsightsChatMessage } from '../../shared/types';
|
||||
import { InsightsModelSelector } from './InsightsModelSelector';
|
||||
import type { InsightsChatMessage, InsightsModelConfig } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_CATEGORY_COLORS,
|
||||
@@ -141,6 +143,13 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelConfigChange = async (config: InsightsModelConfig) => {
|
||||
// If we have a session, persist the config
|
||||
if (session?.id) {
|
||||
await updateModelConfig(projectId, session.id, config);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = status.phase === 'thinking' || status.phase === 'streaming';
|
||||
const messages = session?.messages || [];
|
||||
|
||||
@@ -187,14 +196,21 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<InsightsModelSelector
|
||||
currentConfig={session?.modelConfig}
|
||||
onConfigChange={handleModelConfigChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewSession}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel
|
||||
} from './ui/dropdown-menu';
|
||||
import { DEFAULT_AGENT_PROFILES, AVAILABLE_MODELS } from '../../shared/constants';
|
||||
import type { InsightsModelConfig } from '../../shared/types';
|
||||
import { CustomModelModal } from './CustomModelModal';
|
||||
|
||||
interface InsightsModelSelectorProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onConfigChange: (config: InsightsModelConfig) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap
|
||||
};
|
||||
|
||||
export function InsightsModelSelector({
|
||||
currentConfig,
|
||||
onConfigChange,
|
||||
disabled
|
||||
}: InsightsModelSelectorProps) {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
|
||||
// Default to 'balanced' if no config
|
||||
const selectedProfileId = currentConfig?.profileId || 'balanced';
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedProfileId);
|
||||
|
||||
// Get the appropriate icon
|
||||
const Icon = selectedProfileId === 'custom'
|
||||
? Sliders
|
||||
: (profile?.icon ? iconMap[profile.icon] : Scale);
|
||||
|
||||
const handleSelectProfile = (profileId: string) => {
|
||||
if (profileId === 'custom') {
|
||||
setShowCustomModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = DEFAULT_AGENT_PROFILES.find(p => p.id === profileId);
|
||||
if (selected) {
|
||||
onConfigChange({
|
||||
profileId: selected.id,
|
||||
model: selected.model,
|
||||
thinkingLevel: selected.thinkingLevel
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomSave = (config: InsightsModelConfig) => {
|
||||
onConfigChange(config);
|
||||
setShowCustomModal(false);
|
||||
};
|
||||
|
||||
// Build display text for current selection
|
||||
const getDisplayText = () => {
|
||||
if (selectedProfileId === 'custom' && currentConfig) {
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === currentConfig.model)?.label || currentConfig.model;
|
||||
return `${modelLabel} + ${currentConfig.thinkingLevel}`;
|
||||
}
|
||||
return profile?.name || 'Balanced';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-2 px-2"
|
||||
disabled={disabled}
|
||||
title={`Model: ${getDisplayText()}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Agent Profile</DropdownMenuLabel>
|
||||
{DEFAULT_AGENT_PROFILES.map((p) => {
|
||||
const ProfileIcon = iconMap[p.icon || 'Brain'];
|
||||
const isSelected = selectedProfileId === p.id;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === p.model)?.label;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => handleSelectProfile(p.id)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<ProfileIcon className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{modelLabel} + {p.thinkingLevel}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectProfile('custom')}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Sliders className="h-4 w-4 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Custom...</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Choose model & thinking level
|
||||
</div>
|
||||
</div>
|
||||
{selectedProfileId === 'custom' && (
|
||||
<Check className="h-4 w-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showCustomModal && (
|
||||
<CustomModelModal
|
||||
currentConfig={currentConfig}
|
||||
onSave={handleCustomSave}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function PhaseProgressIndicator({
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{subtasks.slice(0, 10).map((subtask, index) => (
|
||||
<motion.div
|
||||
key={subtask.id}
|
||||
key={subtask.id || `subtask-${index}`}
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
subtask.status === 'completed' && 'bg-success',
|
||||
@@ -185,7 +185,7 @@ export function PhaseProgressIndicator({
|
||||
/>
|
||||
))}
|
||||
{totalSubtasks > 10 && (
|
||||
<span className="text-[10px] text-muted-foreground font-medium ml-0.5">
|
||||
<span key="overflow-count" className="text-[10px] text-muted-foreground font-medium ml-0.5">
|
||||
+{totalSubtasks - 10}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { RoadmapGenerationProgress } from './RoadmapGenerationProgress';
|
||||
import { CompetitorAnalysisDialog } from './CompetitorAnalysisDialog';
|
||||
import { CompetitorAnalysisViewer } from './CompetitorAnalysisViewer';
|
||||
import { AddFeatureDialog } from './AddFeatureDialog';
|
||||
import { RoadmapHeader } from './roadmap/RoadmapHeader';
|
||||
import { RoadmapEmptyState } from './roadmap/RoadmapEmptyState';
|
||||
@@ -16,6 +17,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
const [selectedFeature, setSelectedFeature] = useState<RoadmapFeature | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('phases');
|
||||
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||
const [showCompetitorViewer, setShowCompetitorViewer] = useState(false);
|
||||
|
||||
// Custom hooks
|
||||
const { roadmap, competitorAnalysis, generationStatus } = useRoadmapData(projectId);
|
||||
@@ -27,6 +29,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
handleRefresh,
|
||||
handleCompetitorDialogAccept,
|
||||
handleCompetitorDialogDecline,
|
||||
handleStop,
|
||||
} = useRoadmapGeneration(projectId);
|
||||
|
||||
// Event handlers
|
||||
@@ -47,6 +50,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
<RoadmapGenerationProgress
|
||||
generationStatus={generationStatus}
|
||||
className="w-full max-w-md"
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -73,8 +77,10 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
{/* Header */}
|
||||
<RoadmapHeader
|
||||
roadmap={roadmap}
|
||||
competitorAnalysis={competitorAnalysis}
|
||||
onAddFeature={() => setShowAddFeatureDialog(true)}
|
||||
onRefresh={handleRefresh}
|
||||
onViewCompetitorAnalysis={() => setShowCompetitorViewer(true)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
@@ -108,6 +114,13 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onDecline={handleCompetitorDialogDecline}
|
||||
/>
|
||||
|
||||
{/* Competitor Analysis Viewer */}
|
||||
<CompetitorAnalysisViewer
|
||||
analysis={competitorAnalysis}
|
||||
open={showCompetitorViewer}
|
||||
onOpenChange={setShowCompetitorViewer}
|
||||
/>
|
||||
|
||||
{/* Add Feature Dialog */}
|
||||
<AddFeatureDialog
|
||||
phases={roadmap.phases}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Search, Users, Sparkles, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Search, Users, Sparkles, CheckCircle2, AlertCircle, Square } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RoadmapGenerationStatus } from '../../shared/types/roadmap';
|
||||
|
||||
@@ -36,6 +38,7 @@ function useReducedMotion(): boolean {
|
||||
interface RoadmapGenerationProgressProps {
|
||||
generationStatus: RoadmapGenerationStatus;
|
||||
className?: string;
|
||||
onStop?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
// Type for generation phases (excluding idle)
|
||||
@@ -190,9 +193,27 @@ function PhaseStepsIndicator({
|
||||
export function RoadmapGenerationProgress({
|
||||
generationStatus,
|
||||
className,
|
||||
onStop
|
||||
}: RoadmapGenerationProgressProps) {
|
||||
const { phase, progress, message, error } = generationStatus;
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle stop button click with error handling and double-click prevention
|
||||
*/
|
||||
const handleStopClick = async () => {
|
||||
if (!onStop || isStopping) return;
|
||||
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await onStop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop generation:', err);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render anything for idle phase
|
||||
if (phase === 'idle') {
|
||||
@@ -248,6 +269,26 @@ export function RoadmapGenerationProgress({
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4 p-6 rounded-xl bg-card border', className)}>
|
||||
{/* Header with Stop button */}
|
||||
{isActivePhase && onStop && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleStopClick}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
{isStopping ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop generation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main phase display */}
|
||||
<div className="flex flex-col items-center text-center space-y-3">
|
||||
{/* Animated icon with pulsing animation for active phase */}
|
||||
|
||||
@@ -199,12 +199,15 @@ export function Sidebar({
|
||||
const handleInitialize = async () => {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
setIsInitializing(true);
|
||||
try {
|
||||
const result = await initializeProject(pendingProject.id);
|
||||
const result = await initializeProject(projectId);
|
||||
if (result?.success) {
|
||||
setShowInitDialog(false);
|
||||
// Clear pendingProject FIRST before closing dialog
|
||||
// This prevents onOpenChange from triggering skip logic
|
||||
setPendingProject(null);
|
||||
setShowInitDialog(false);
|
||||
}
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
@@ -474,7 +477,12 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
// Only allow closing if user manually closes (not during initialization)
|
||||
if (!open && !isInitializing) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -40,10 +40,12 @@ import {
|
||||
} from './ImageUpload';
|
||||
import { ReferencedFilesSection } from './ReferencedFilesSection';
|
||||
import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer';
|
||||
import { AgentProfileSelector } from './AgentProfileSelector';
|
||||
import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
@@ -53,8 +55,8 @@ import {
|
||||
MAX_REFERENCED_FILES,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY,
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
|
||||
@@ -73,7 +75,7 @@ export function TaskCreationWizard({
|
||||
const { settings } = useSettingsStore();
|
||||
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.id === settings.selectedAgentProfile
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'balanced')!;
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -81,7 +83,6 @@ export function TaskCreationWizard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showImages, setShowImages] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(false);
|
||||
|
||||
// Get project path from project store
|
||||
@@ -98,8 +99,16 @@ export function TaskCreationWizard({
|
||||
const [impact, setImpact] = useState<TaskImpact | ''>('');
|
||||
|
||||
// Model configuration (initialized from selected agent profile)
|
||||
const [profileId, setProfileId] = useState<string>(settings.selectedAgentProfile || 'auto');
|
||||
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
|
||||
// Auto profile - per-phase configuration
|
||||
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
|
||||
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
);
|
||||
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
|
||||
selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
);
|
||||
|
||||
// Image attachments
|
||||
const [images, setImages] = useState<ImageAttachment[]>([]);
|
||||
@@ -136,12 +145,18 @@ export function TaskCreationWizard({
|
||||
})
|
||||
);
|
||||
|
||||
// Setup drop zone for file references
|
||||
// Setup drop zone for file references (entire form)
|
||||
const { setNodeRef: setDropRef, isOver: isOverDropZone } = useDroppable({
|
||||
id: 'file-drop-zone',
|
||||
data: { type: 'file-drop-zone' }
|
||||
});
|
||||
|
||||
// Setup drop zone for description textarea (inline @mentions)
|
||||
const { setNodeRef: setTextareaDropRef, isOver: isOverTextarea } = useDroppable({
|
||||
id: 'description-drop-zone',
|
||||
data: { type: 'description-drop-zone' }
|
||||
});
|
||||
|
||||
// Determine if drop zone is at capacity
|
||||
const isAtMaxFiles = referencedFiles.length >= MAX_REFERENCED_FILES;
|
||||
|
||||
@@ -156,9 +171,12 @@ export function TaskCreationWizard({
|
||||
setPriority(draft.priority);
|
||||
setComplexity(draft.complexity);
|
||||
setImpact(draft.impact);
|
||||
// Load model/thinkingLevel from draft if present, otherwise use profile defaults
|
||||
// Load model/thinkingLevel/profileId from draft if present, otherwise use profile defaults
|
||||
setProfileId(draft.profileId || settings.selectedAgentProfile || 'auto');
|
||||
setModel(draft.model || selectedProfile.model);
|
||||
setThinkingLevel(draft.thinkingLevel || selectedProfile.thinkingLevel);
|
||||
setPhaseModels(draft.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(draft.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages(draft.images);
|
||||
setReferencedFiles(draft.referencedFiles ?? []);
|
||||
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
|
||||
@@ -171,16 +189,17 @@ export function TaskCreationWizard({
|
||||
if (draft.images.length > 0) {
|
||||
setShowImages(true);
|
||||
}
|
||||
if (draft.referencedFiles && draft.referencedFiles.length > 0) {
|
||||
setShowFiles(true);
|
||||
}
|
||||
// Note: Referenced Files section is always visible, no need to expand
|
||||
} else {
|
||||
// No draft - initialize model/thinkingLevel from selected profile
|
||||
// No draft - initialize from selected profile
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
}
|
||||
}
|
||||
}, [open, projectId, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
}, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
/**
|
||||
* Get current form state as a draft
|
||||
@@ -193,13 +212,16 @@ export function TaskCreationWizard({
|
||||
priority,
|
||||
complexity,
|
||||
impact,
|
||||
profileId,
|
||||
model,
|
||||
thinkingLevel,
|
||||
phaseModels,
|
||||
phaseThinking,
|
||||
images,
|
||||
referencedFiles,
|
||||
requireReviewBeforeCoding,
|
||||
savedAt: new Date()
|
||||
}), [projectId, title, description, category, priority, complexity, impact, model, thinkingLevel, images, referencedFiles, requireReviewBeforeCoding]);
|
||||
}), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]);
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
*/
|
||||
@@ -400,7 +422,7 @@ export function TaskCreationWizard({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drag end - add file to referencedFiles when dropped on valid target
|
||||
* Handle drag end - insert @mention in description or add to referencedFiles
|
||||
*/
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -411,9 +433,6 @@ export function TaskCreationWizard({
|
||||
// If not dropped on a valid target, do nothing
|
||||
if (!over) return;
|
||||
|
||||
// Only accept drops on the file-drop-zone
|
||||
if (over.id !== 'file-drop-zone') return;
|
||||
|
||||
const data = active.data.current as {
|
||||
type?: string;
|
||||
path?: string;
|
||||
@@ -424,32 +443,89 @@ export function TaskCreationWizard({
|
||||
// Only process file drops
|
||||
if (data?.type !== 'file' || !data.path || !data.name) return;
|
||||
|
||||
// Check if we're at the max limit
|
||||
if (referencedFiles.length >= MAX_REFERENCED_FILES) {
|
||||
setError(`Maximum of ${MAX_REFERENCED_FILES} referenced files allowed`);
|
||||
// Handle drop on description textarea - insert inline @mention
|
||||
if (over.id === 'description-drop-zone') {
|
||||
const textarea = descriptionRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const cursorPos = textarea.selectionStart || 0;
|
||||
const textBefore = description.substring(0, cursorPos);
|
||||
const textAfter = description.substring(cursorPos);
|
||||
|
||||
// Insert @mention at cursor position
|
||||
const mention = `@${data.name}`;
|
||||
const newDescription = textBefore + mention + textAfter;
|
||||
setDescription(newDescription);
|
||||
|
||||
// Set cursor after the inserted mention
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = cursorPos + mention.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (referencedFiles.some(f => f.path === data.path)) {
|
||||
// Silently skip duplicates
|
||||
return;
|
||||
// Handle drop on file-drop-zone - add to referenced files list
|
||||
if (over.id === 'file-drop-zone') {
|
||||
// Check if we're at the max limit
|
||||
if (referencedFiles.length >= MAX_REFERENCED_FILES) {
|
||||
setError(`Maximum of ${MAX_REFERENCED_FILES} referenced files allowed`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (referencedFiles.some(f => f.path === data.path)) {
|
||||
// Silently skip duplicates
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the file to referenced files
|
||||
const newFile: ReferencedFile = {
|
||||
id: crypto.randomUUID(),
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
isDirectory: data.isDirectory ?? false,
|
||||
addedAt: new Date()
|
||||
};
|
||||
|
||||
setReferencedFiles(prev => [...prev, newFile]);
|
||||
}
|
||||
}, [referencedFiles, description]);
|
||||
|
||||
// Add the file to referenced files
|
||||
const newFile: ReferencedFile = {
|
||||
id: crypto.randomUUID(),
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
isDirectory: data.isDirectory ?? false,
|
||||
addedAt: new Date()
|
||||
};
|
||||
/**
|
||||
* Parse @mentions from description and create ReferencedFile entries
|
||||
* Merges with existing referencedFiles, avoiding duplicates
|
||||
*/
|
||||
const parseFileMentions = useCallback((text: string, existingFiles: ReferencedFile[]): ReferencedFile[] => {
|
||||
// Match @filename patterns (supports filenames with dots, hyphens, underscores, and path separators)
|
||||
const mentionRegex = /@([\w\-./\\]+\.\w+)/g;
|
||||
const matches = Array.from(text.matchAll(mentionRegex));
|
||||
|
||||
setReferencedFiles(prev => [...prev, newFile]);
|
||||
if (matches.length === 0) return existingFiles;
|
||||
|
||||
// Auto-expand the files section when a file is added
|
||||
setShowFiles(true);
|
||||
}, [referencedFiles]);
|
||||
// Create a set of existing file names for quick lookup
|
||||
const existingNames = new Set(existingFiles.map(f => f.name));
|
||||
|
||||
// Parse mentioned files that aren't already in the list
|
||||
const newFiles: ReferencedFile[] = [];
|
||||
matches.forEach(match => {
|
||||
const fileName = match[1];
|
||||
if (!existingNames.has(fileName)) {
|
||||
newFiles.push({
|
||||
id: crypto.randomUUID(),
|
||||
path: fileName, // Store relative path from @mention
|
||||
name: fileName,
|
||||
isDirectory: false,
|
||||
addedAt: new Date()
|
||||
});
|
||||
existingNames.add(fileName); // Prevent duplicates within mentions
|
||||
}
|
||||
});
|
||||
|
||||
return [...existingFiles, ...newFiles];
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!description.trim()) {
|
||||
@@ -461,6 +537,9 @@ export function TaskCreationWizard({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Parse @mentions from description and merge with referenced files
|
||||
const allReferencedFiles = parseFileMentions(description, referencedFiles);
|
||||
|
||||
// Build metadata from selected values
|
||||
const metadata: TaskMetadata = {
|
||||
sourceType: 'manual'
|
||||
@@ -472,8 +551,14 @@ export function TaskCreationWizard({
|
||||
if (impact) metadata.impact = impact;
|
||||
if (model) metadata.model = model;
|
||||
if (thinkingLevel) metadata.thinkingLevel = thinkingLevel;
|
||||
// Auto profile - per-phase configuration
|
||||
if (profileId === 'auto') {
|
||||
metadata.isAutoProfile = true;
|
||||
if (phaseModels) metadata.phaseModels = phaseModels;
|
||||
if (phaseThinking) metadata.phaseThinking = phaseThinking;
|
||||
}
|
||||
if (images.length > 0) metadata.attachedImages = images;
|
||||
if (referencedFiles.length > 0) metadata.referencedFiles = referencedFiles;
|
||||
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
|
||||
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
|
||||
|
||||
// Title is optional - if empty, it will be auto-generated by the backend
|
||||
@@ -501,16 +586,18 @@ export function TaskCreationWizard({
|
||||
setPriority('');
|
||||
setComplexity('');
|
||||
setImpact('');
|
||||
// Reset model/thinkingLevel to selected profile defaults
|
||||
// Reset to selected profile defaults
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages([]);
|
||||
setReferencedFiles([]);
|
||||
setRequireReviewBeforeCoding(false);
|
||||
setError(null);
|
||||
setShowAdvanced(false);
|
||||
setShowImages(false);
|
||||
setShowFiles(false);
|
||||
setShowFileExplorer(false);
|
||||
setIsDraftRestored(false);
|
||||
setPasteSuccess(false);
|
||||
@@ -560,8 +647,39 @@ export function TaskCreationWizard({
|
||||
hideCloseButton={showFileExplorer}
|
||||
>
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* Form content */}
|
||||
<div className="flex-1 flex flex-col p-6 min-w-0 min-h-0 overflow-y-auto">
|
||||
{/* Form content - Drop zone wrapper */}
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col p-6 min-w-0 min-h-0 overflow-y-auto relative transition-all duration-150 ease-out",
|
||||
// Default state - no border
|
||||
!activeDragData && "",
|
||||
// Subtle visual feedback when dragging - border on the entire form
|
||||
activeDragData && !isOverDropZone && "border-2 border-dashed border-muted-foreground/40 rounded-lg",
|
||||
// Clear drop target feedback when over the form
|
||||
activeDragData && isOverDropZone && !isAtMaxFiles && "border-2 border-solid border-info rounded-lg bg-info/5",
|
||||
// Warning state when at capacity
|
||||
activeDragData && isOverDropZone && isAtMaxFiles && "border-2 border-solid border-warning rounded-lg bg-warning/5"
|
||||
)}
|
||||
>
|
||||
{/* Drop zone indicator overlay - shows when dragging over form */}
|
||||
{activeDragData && isOverDropZone && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center pointer-events-none rounded-lg">
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium shadow-lg",
|
||||
isAtMaxFiles
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-info text-info-foreground"
|
||||
)}>
|
||||
<FileDown className="h-4 w-4" />
|
||||
<span>
|
||||
{isAtMaxFiles
|
||||
? `Maximum ${MAX_REFERENCED_FILES} files reached`
|
||||
: 'Drop file to add reference'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-foreground">Create New Task</DialogTitle>
|
||||
@@ -594,25 +712,67 @@ export function TaskCreationWizard({
|
||||
<Label htmlFor="description" className="text-sm font-medium text-foreground">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
id="description"
|
||||
placeholder="Describe the feature, bug fix, or improvement you want to implement. Be as specific as possible about requirements, constraints, and expected behavior."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleTextareaDragOver}
|
||||
onDragLeave={handleTextareaDragLeave}
|
||||
onDrop={handleTextareaDrop}
|
||||
rows={5}
|
||||
disabled={isCreating}
|
||||
className={cn(
|
||||
"resize-y min-h-[120px] max-h-[400px]",
|
||||
isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
{/* Wrap textarea in drop zone for file @mentions */}
|
||||
<div ref={setTextareaDropRef} className="relative">
|
||||
{/* Syntax highlight overlay for @mentions */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none overflow-hidden rounded-md border border-transparent"
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
font: 'inherit',
|
||||
lineHeight: '1.5',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: 'transparent'
|
||||
}}
|
||||
>
|
||||
{description.split(/(@[\w\-./\\]+\.\w+)/g).map((part, i) => {
|
||||
// Check if this part is an @mention
|
||||
if (part.match(/^@[\w\-./\\]+\.\w+$/)) {
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-info/20 text-info-foreground rounded px-0.5"
|
||||
style={{ color: 'hsl(var(--info))' }}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={descriptionRef}
|
||||
id="description"
|
||||
placeholder="Describe the feature, bug fix, or improvement you want to implement. Be as specific as possible about requirements, constraints, and expected behavior."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleTextareaDragOver}
|
||||
onDragLeave={handleTextareaDragLeave}
|
||||
onDrop={handleTextareaDrop}
|
||||
rows={5}
|
||||
disabled={isCreating}
|
||||
className={cn(
|
||||
"resize-y min-h-[120px] max-h-[400px] relative bg-transparent",
|
||||
// Image drop feedback (native drops)
|
||||
isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/20",
|
||||
// File reference drop feedback (dnd-kit drops for @mentions)
|
||||
activeDragData && isOverTextarea && "border-info bg-info/5 ring-2 ring-info/20"
|
||||
)}
|
||||
style={{ caretColor: 'auto' }}
|
||||
/>
|
||||
{/* Drop indicator for file references */}
|
||||
{activeDragData && isOverTextarea && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 rounded-md bg-info text-info-foreground text-xs font-medium shadow-sm pointer-events-none z-10">
|
||||
<File className="h-3 w-3" />
|
||||
<span>Insert @{activeDragData.name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: Paste screenshots directly with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'} to add reference images.
|
||||
Tip: Drag files from the explorer to insert @references, or paste screenshots with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -633,57 +793,24 @@ export function TaskCreationWizard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model" className="text-sm font-medium text-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => setModel(value as ModelType)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger id="model" className="h-9">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The Claude model to use for this task. Defaults to your selected agent profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Thinking Level Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="thinking-level" className="text-sm font-medium text-foreground">
|
||||
Thinking Level
|
||||
</Label>
|
||||
<Select
|
||||
value={thinkingLevel}
|
||||
onValueChange={(value) => setThinkingLevel(value as ThinkingLevel)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<SelectTrigger id="thinking-level" className="h-9">
|
||||
<SelectValue placeholder="Select thinking level" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Extended thinking depth for complex reasoning. Higher levels use more tokens but provide deeper analysis.
|
||||
</p>
|
||||
</div>
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
|
||||
setProfileId(newProfileId);
|
||||
setModel(newModel);
|
||||
setThinkingLevel(newThinkingLevel);
|
||||
}}
|
||||
onModelChange={setModel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
onPhaseModelsChange={setPhaseModels}
|
||||
onPhaseThinkingChange={setPhaseThinking}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
@@ -854,104 +981,38 @@ export function TaskCreationWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference Files Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFiles(!showFiles)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
||||
'w-full justify-between py-2 px-3 rounded-md hover:bg-muted/50'
|
||||
)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
Reference Files (optional)
|
||||
{/* Referenced Files Section - Always visible, clean list */}
|
||||
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Referenced Files</span>
|
||||
{referencedFiles.length > 0 && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{referencedFiles.length}
|
||||
{referencedFiles.length}/{MAX_REFERENCED_FILES}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showFiles ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Referenced Files Section - Drop Zone */}
|
||||
{showFiles ? (
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={cn(
|
||||
"space-y-3 p-4 rounded-lg border bg-muted/30 relative transition-all",
|
||||
isOverDropZone && !isAtMaxFiles && "ring-2 ring-info border-info",
|
||||
isOverDropZone && isAtMaxFiles && "ring-2 ring-warning border-warning",
|
||||
!isOverDropZone && "border-border"
|
||||
)}
|
||||
>
|
||||
{/* Drop zone overlay indicator */}
|
||||
{isOverDropZone && (
|
||||
<div className={cn(
|
||||
"absolute inset-0 z-10 flex items-center justify-center pointer-events-none rounded-lg",
|
||||
isAtMaxFiles ? "bg-warning/10" : "bg-info/10"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md",
|
||||
isAtMaxFiles ? "bg-warning/90 text-warning-foreground" : "bg-info/90 text-info-foreground"
|
||||
)}>
|
||||
<FileDown className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{isAtMaxFiles ? `Max ${MAX_REFERENCED_FILES} files reached` : 'Drop to add reference'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference specific files or folders from your project to provide context for the AI.
|
||||
</p>
|
||||
<ReferencedFilesSection
|
||||
files={referencedFiles}
|
||||
onRemove={(id) => setReferencedFiles(prev => prev.filter(f => f.id !== id))}
|
||||
maxFiles={MAX_REFERENCED_FILES}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
{referencedFiles.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No files referenced yet. Drag files from the file explorer to add them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Compact drop zone when section is collapsed - only visible during drag */
|
||||
activeDragData && (
|
||||
<div
|
||||
ref={setDropRef}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border-2 border-dashed flex items-center justify-center gap-2 transition-all animate-in fade-in slide-in-from-top-2 duration-200",
|
||||
isOverDropZone && !isAtMaxFiles && "border-info bg-info/10",
|
||||
isOverDropZone && isAtMaxFiles && "border-warning bg-warning/10",
|
||||
!isOverDropZone && "border-muted-foreground/30 bg-muted/20"
|
||||
)}
|
||||
>
|
||||
<FileDown className={cn(
|
||||
"h-4 w-4",
|
||||
isOverDropZone && !isAtMaxFiles && "text-info",
|
||||
isOverDropZone && isAtMaxFiles && "text-warning",
|
||||
!isOverDropZone && "text-muted-foreground"
|
||||
)} />
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
isOverDropZone && !isAtMaxFiles && "text-info font-medium",
|
||||
isOverDropZone && isAtMaxFiles && "text-warning font-medium",
|
||||
!isOverDropZone && "text-muted-foreground"
|
||||
)}>
|
||||
{isAtMaxFiles ? `Max ${MAX_REFERENCED_FILES} files reached` : 'Drop file here to add reference'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Empty state hint */}
|
||||
{referencedFiles.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drag files from the file explorer anywhere onto this form to add references, or use the "Browse Files" button below.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These files will provide context for the AI when working on your task.
|
||||
</p>
|
||||
<ReferencedFilesSection
|
||||
files={referencedFiles}
|
||||
onRemove={(id) => setReferencedFiles(prev => prev.filter(f => f.id !== id))}
|
||||
maxFiles={MAX_REFERENCED_FILES}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review Requirement Toggle */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border border-border bg-muted/30">
|
||||
|
||||
@@ -54,17 +54,23 @@ import {
|
||||
isValidImageMimeType,
|
||||
resolveFilename
|
||||
} from './ImageUpload';
|
||||
import { AgentProfileSelector } from './AgentProfileSelector';
|
||||
import { persistUpdateTask } from '../stores/task-store';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact } from '../../shared/types';
|
||||
import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity, TaskImpact, ModelType, ThinkingLevel } from '../../shared/types';
|
||||
import {
|
||||
TASK_CATEGORY_LABELS,
|
||||
TASK_PRIORITY_LABELS,
|
||||
TASK_COMPLEXITY_LABELS,
|
||||
TASK_IMPACT_LABELS,
|
||||
MAX_IMAGES_PER_TASK,
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY
|
||||
ALLOWED_IMAGE_TYPES_DISPLAY,
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../shared/constants';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
|
||||
/**
|
||||
* Props for the TaskEditDialog component
|
||||
@@ -81,6 +87,12 @@ interface TaskEditDialogProps {
|
||||
}
|
||||
|
||||
export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDialogProps) {
|
||||
// Get selected agent profile from settings for defaults
|
||||
const { settings } = useSettingsStore();
|
||||
const selectedProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.id === settings.selectedAgentProfile
|
||||
) || DEFAULT_AGENT_PROFILES.find(p => p.id === 'auto')!;
|
||||
|
||||
const [title, setTitle] = useState(task.title);
|
||||
const [description, setDescription] = useState(task.description);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -95,6 +107,36 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
const [complexity, setComplexity] = useState<TaskComplexity | ''>(task.metadata?.complexity || '');
|
||||
const [impact, setImpact] = useState<TaskImpact | ''>(task.metadata?.impact || '');
|
||||
|
||||
// Agent profile / model configuration
|
||||
const [profileId, setProfileId] = useState<string>(() => {
|
||||
// Check if task uses Auto profile
|
||||
if (task.metadata?.isAutoProfile) {
|
||||
return 'auto';
|
||||
}
|
||||
// Determine profile ID from task metadata or default to 'auto'
|
||||
const taskModel = task.metadata?.model;
|
||||
const taskThinking = task.metadata?.thinkingLevel;
|
||||
if (taskModel && taskThinking) {
|
||||
// Check if it matches a known profile
|
||||
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
|
||||
);
|
||||
return matchingProfile?.id || 'custom';
|
||||
}
|
||||
return settings.selectedAgentProfile || 'auto';
|
||||
});
|
||||
const [model, setModel] = useState<ModelType | ''>(task.metadata?.model || selectedProfile.model);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(
|
||||
task.metadata?.thinkingLevel || selectedProfile.thinkingLevel
|
||||
);
|
||||
// Auto profile - per-phase configuration
|
||||
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
|
||||
task.metadata?.phaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
);
|
||||
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
|
||||
task.metadata?.phaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
);
|
||||
|
||||
// Image attachments
|
||||
const [images, setImages] = useState<ImageAttachment[]>(task.metadata?.attachedImages || []);
|
||||
|
||||
@@ -118,6 +160,35 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
setPriority(task.metadata?.priority || '');
|
||||
setComplexity(task.metadata?.complexity || '');
|
||||
setImpact(task.metadata?.impact || '');
|
||||
|
||||
// Reset model configuration
|
||||
const taskModel = task.metadata?.model;
|
||||
const taskThinking = task.metadata?.thinkingLevel;
|
||||
const isAutoProfile = task.metadata?.isAutoProfile;
|
||||
|
||||
if (isAutoProfile) {
|
||||
setProfileId('auto');
|
||||
setModel(taskModel || selectedProfile.model);
|
||||
setThinkingLevel(taskThinking || selectedProfile.thinkingLevel);
|
||||
setPhaseModels(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
} else if (taskModel && taskThinking) {
|
||||
const matchingProfile = DEFAULT_AGENT_PROFILES.find(
|
||||
p => p.model === taskModel && p.thinkingLevel === taskThinking && !p.isAutoProfile
|
||||
);
|
||||
setProfileId(matchingProfile?.id || 'custom');
|
||||
setModel(taskModel);
|
||||
setThinkingLevel(taskThinking);
|
||||
setPhaseModels(DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(DEFAULT_PHASE_THINKING);
|
||||
} else {
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
}
|
||||
|
||||
setImages(task.metadata?.attachedImages || []);
|
||||
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
|
||||
setError(null);
|
||||
@@ -130,7 +201,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
setShowImages((task.metadata?.attachedImages || []).length > 0);
|
||||
setPasteSuccess(false);
|
||||
}
|
||||
}, [open, task]);
|
||||
}, [open, task, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
/**
|
||||
* Handle paste event for screenshot support
|
||||
@@ -328,6 +399,8 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
priority !== (task.metadata?.priority || '') ||
|
||||
complexity !== (task.metadata?.complexity || '') ||
|
||||
impact !== (task.metadata?.impact || '') ||
|
||||
model !== (task.metadata?.model || '') ||
|
||||
thinkingLevel !== (task.metadata?.thinkingLevel || '') ||
|
||||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
|
||||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []);
|
||||
|
||||
@@ -346,6 +419,17 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
if (priority) metadataUpdates.priority = priority;
|
||||
if (complexity) metadataUpdates.complexity = complexity;
|
||||
if (impact) metadataUpdates.impact = impact;
|
||||
if (model) metadataUpdates.model = model as ModelType;
|
||||
if (thinkingLevel) metadataUpdates.thinkingLevel = thinkingLevel as ThinkingLevel;
|
||||
// Auto profile - per-phase configuration
|
||||
if (profileId === 'auto') {
|
||||
metadataUpdates.isAutoProfile = true;
|
||||
if (phaseModels) metadataUpdates.phaseModels = phaseModels;
|
||||
if (phaseThinking) metadataUpdates.phaseThinking = phaseThinking;
|
||||
} else {
|
||||
// Clear auto profile fields if switching away from auto
|
||||
metadataUpdates.isAutoProfile = false;
|
||||
}
|
||||
if (images.length > 0) metadataUpdates.attachedImages = images;
|
||||
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
|
||||
|
||||
@@ -429,6 +513,25 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSelector
|
||||
profileId={profileId}
|
||||
model={model}
|
||||
thinkingLevel={thinkingLevel}
|
||||
phaseModels={phaseModels}
|
||||
phaseThinking={phaseThinking}
|
||||
onProfileChange={(newProfileId, newModel, newThinkingLevel) => {
|
||||
setProfileId(newProfileId);
|
||||
setModel(newModel);
|
||||
setThinkingLevel(newThinkingLevel);
|
||||
}}
|
||||
onModelChange={setModel}
|
||||
onThinkingLevelChange={setThinkingLevel}
|
||||
onPhaseModelsChange={setPhaseModels}
|
||||
onPhaseThinkingChange={setPhaseThinking}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Paste Success Indicator */}
|
||||
{pasteSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-success animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
|
||||
+148
-15
@@ -1,6 +1,9 @@
|
||||
import { Sparkles, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sparkles, Loader2, CheckCircle2, MessageCircle } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Progress } from '../../ui/progress';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +13,15 @@ import {
|
||||
DialogTitle
|
||||
} from '../../ui/dialog';
|
||||
import type { InvestigationDialogProps } from '../types';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
interface GitHubComment {
|
||||
id: number;
|
||||
body: string;
|
||||
user: { login: string; avatar_url?: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function InvestigationDialog({
|
||||
open,
|
||||
@@ -17,11 +29,75 @@ export function InvestigationDialog({
|
||||
selectedIssue,
|
||||
investigationStatus,
|
||||
onStartInvestigation,
|
||||
onClose
|
||||
onClose,
|
||||
projectId
|
||||
}: InvestigationDialogProps) {
|
||||
const [comments, setComments] = useState<GitHubComment[]>([]);
|
||||
const [selectedCommentIds, setSelectedCommentIds] = useState<number[]>([]);
|
||||
const [loadingComments, setLoadingComments] = useState(false);
|
||||
const [fetchCommentsError, setFetchCommentsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch comments when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && selectedIssue && projectId) {
|
||||
let isMounted = true;
|
||||
|
||||
setLoadingComments(true);
|
||||
setComments([]);
|
||||
setSelectedCommentIds([]);
|
||||
setFetchCommentsError(null);
|
||||
|
||||
window.electronAPI.getIssueComments(projectId, selectedIssue.number)
|
||||
.then((result: { success: boolean; data?: GitHubComment[] }) => {
|
||||
if (!isMounted) return;
|
||||
if (result.success && result.data) {
|
||||
setComments(result.data);
|
||||
// By default, select all comments
|
||||
setSelectedCommentIds(result.data.map((c: GitHubComment) => c.id));
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!isMounted) return;
|
||||
console.error('Failed to fetch comments:', err);
|
||||
setFetchCommentsError(
|
||||
err instanceof Error ? err.message : 'Failed to load comments'
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) {
|
||||
setLoadingComments(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}
|
||||
}, [open, selectedIssue, projectId]);
|
||||
|
||||
const toggleComment = (commentId: number) => {
|
||||
setSelectedCommentIds(prev =>
|
||||
prev.includes(commentId)
|
||||
? prev.filter(id => id !== commentId)
|
||||
: [...prev, commentId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllComments = () => {
|
||||
if (selectedCommentIds.length === comments.length) {
|
||||
setSelectedCommentIds([]);
|
||||
} else {
|
||||
setSelectedCommentIds(comments.map(c => c.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartInvestigation = () => {
|
||||
onStartInvestigation(selectedCommentIds);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-info" />
|
||||
@@ -37,19 +113,76 @@ export function InvestigationDialog({
|
||||
</DialogHeader>
|
||||
|
||||
{investigationStatus.phase === 'idle' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 flex-1 min-h-0 flex flex-col">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a task from this GitHub issue. The task will be added to your Kanban board in the Planned column.
|
||||
Create a task from this GitHub issue. The task will be added to your Kanban board in the Backlog column.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<h4 className="text-sm font-medium mb-2">The task will include:</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Issue title and description</li>
|
||||
<li>• Link back to the GitHub issue</li>
|
||||
<li>• Labels and metadata from the issue</li>
|
||||
<li>• Ready to start when you move it to In Progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Comments section */}
|
||||
{loadingComments ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : fetchCommentsError ? (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
||||
<p className="text-sm text-destructive font-medium">Failed to load comments</p>
|
||||
<p className="text-xs text-destructive/80 mt-1">{fetchCommentsError}</p>
|
||||
</div>
|
||||
) : comments.length > 0 ? (
|
||||
<div className="space-y-2 flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Select Comments to Include ({selectedCommentIds.length}/{comments.length})
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleAllComments}
|
||||
className="text-xs"
|
||||
>
|
||||
{selectedCommentIds.length === comments.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 min-h-0 border rounded-md">
|
||||
<div className="p-2 space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="flex gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => toggleComment(comment.id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedCommentIds.includes(comment.id)}
|
||||
onCheckedChange={() => toggleComment(comment.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">{comment.user.login}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(comment.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-words line-clamp-3">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<h4 className="text-sm font-medium mb-2">The task will include:</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Issue title and description</li>
|
||||
<li>• Link back to the GitHub issue</li>
|
||||
<li>• Labels and metadata from the issue</li>
|
||||
<li>• No comments (this issue has no comments)</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -82,7 +215,7 @@ export function InvestigationDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onStartInvestigation}>
|
||||
<Button onClick={handleStartInvestigation}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, User, Clock, MessageCircle, Sparkles, CheckCircle2 } from 'lucide-react';
|
||||
import { ExternalLink, User, Clock, MessageCircle, Sparkles, CheckCircle2, Eye } from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
@@ -11,7 +11,17 @@ import {
|
||||
import { formatDate } from '../utils';
|
||||
import type { IssueDetailProps } from '../types';
|
||||
|
||||
export function IssueDetail({ issue, onInvestigate, investigationResult }: IssueDetailProps) {
|
||||
export function IssueDetail({ issue, onInvestigate, investigationResult, linkedTaskId, onViewTask }: IssueDetailProps) {
|
||||
// Determine which task ID to use - either already linked or just created
|
||||
const taskId = linkedTaskId || (investigationResult?.success ? investigationResult.taskId : undefined);
|
||||
const hasLinkedTask = !!taskId;
|
||||
|
||||
const handleViewTask = () => {
|
||||
if (taskId && onViewTask) {
|
||||
onViewTask(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
@@ -77,31 +87,48 @@ export function IssueDetail({ issue, onInvestigate, investigationResult }: Issue
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onInvestigate} className="flex-1">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
{hasLinkedTask ? (
|
||||
<Button onClick={handleViewTask} className="flex-1" variant="secondary">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onInvestigate} className="flex-1">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Create Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Created Result */}
|
||||
{investigationResult?.success && (
|
||||
{/* Task Linked Info */}
|
||||
{hasLinkedTask && (
|
||||
<Card className="bg-success/5 border-success/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Task Created
|
||||
Task Linked
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm space-y-2">
|
||||
<p className="text-foreground">{investigationResult.analysis.summary}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={GITHUB_COMPLEXITY_COLORS[investigationResult.analysis.estimatedComplexity]}>
|
||||
{investigationResult.analysis.estimatedComplexity}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {investigationResult.taskId}
|
||||
</span>
|
||||
</div>
|
||||
{investigationResult?.success ? (
|
||||
<>
|
||||
<p className="text-foreground">{investigationResult.analysis.summary}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={GITHUB_COMPLEXITY_COLORS[investigationResult.analysis.estimatedComplexity]}>
|
||||
{investigationResult.analysis.estimatedComplexity}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {taskId}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Task ID: {taskId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
+2
-2
@@ -56,9 +56,9 @@ export function useGitHubInvestigation(projectId: string | undefined) {
|
||||
};
|
||||
}, [projectId, setInvestigationStatus, setInvestigationResult, setError]);
|
||||
|
||||
const startInvestigation = useCallback((issue: GitHubIssue) => {
|
||||
const startInvestigation = useCallback((issue: GitHubIssue, selectedCommentIds: number[]) => {
|
||||
if (projectId) {
|
||||
investigateGitHubIssue(projectId, issue.number);
|
||||
investigateGitHubIssue(projectId, issue.number, selectedCommentIds);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export type FilterState = 'open' | 'closed' | 'all';
|
||||
|
||||
export interface GitHubIssuesProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** Navigate to view a task in the kanban board */
|
||||
onNavigateToTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface IssueListItemProps {
|
||||
@@ -17,6 +19,10 @@ export interface IssueDetailProps {
|
||||
issue: GitHubIssue;
|
||||
onInvestigate: () => void;
|
||||
investigationResult: GitHubInvestigationResult | null;
|
||||
/** ID of existing task linked to this issue (from metadata.githubIssueNumber) */
|
||||
linkedTaskId?: string;
|
||||
/** Handler to navigate to view the linked task */
|
||||
onViewTask?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export interface InvestigationDialogProps {
|
||||
@@ -29,8 +35,9 @@ export interface InvestigationDialogProps {
|
||||
message: string;
|
||||
error?: string;
|
||||
};
|
||||
onStartInvestigation: () => void;
|
||||
onStartInvestigation: (selectedCommentIds: number[]) => void;
|
||||
onClose: () => void;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface IssueListHeaderProps {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface GenerationProgressScreenProps {
|
||||
onConvert: (idea: Idea) => void;
|
||||
onGoToTask?: (taskId: string) => void;
|
||||
onDismiss: (idea: Idea) => void;
|
||||
onStop: () => void;
|
||||
onStop: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function GenerationProgressScreen({
|
||||
@@ -51,6 +51,23 @@ export function GenerationProgressScreen({
|
||||
}: GenerationProgressScreenProps) {
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle stop button click with error handling and double-click prevention
|
||||
*/
|
||||
const handleStopClick = async () => {
|
||||
if (isStopping) return;
|
||||
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await onStop();
|
||||
} catch (err) {
|
||||
console.error('Failed to stop generation:', err);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when logs update
|
||||
useEffect(() => {
|
||||
@@ -98,10 +115,11 @@ export function GenerationProgressScreen({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onStop}
|
||||
onClick={handleStopClick}
|
||||
disabled={isStopping}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
{isStopping ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop generation</TooltipContent>
|
||||
|
||||
@@ -116,6 +116,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
|
||||
onConfigured={handleEnvConfigured}
|
||||
title="Claude Authentication Required"
|
||||
description="A Claude Code OAuth token is required to generate AI-powered feature ideas."
|
||||
projectId={projectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -239,6 +240,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
|
||||
onConfigured={handleEnvConfigured}
|
||||
title="Claude Authentication Required"
|
||||
description="A Claude Code OAuth token is required to generate AI-powered feature ideas."
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Target, Users, BarChart3, RefreshCw, Plus } from 'lucide-react';
|
||||
import { Target, Users, BarChart3, RefreshCw, Plus, TrendingUp } from 'lucide-react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
@@ -6,7 +6,7 @@ import { getFeatureStats } from '../../stores/roadmap-store';
|
||||
import { ROADMAP_PRIORITY_COLORS } from '../../../shared/constants';
|
||||
import type { RoadmapHeaderProps } from './types';
|
||||
|
||||
export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeaderProps) {
|
||||
export function RoadmapHeader({ roadmap, competitorAnalysis, onAddFeature, onRefresh, onViewCompetitorAnalysis }: RoadmapHeaderProps) {
|
||||
const stats = getFeatureStats(roadmap);
|
||||
|
||||
return (
|
||||
@@ -17,6 +17,29 @@ export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeade
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{roadmap.projectName}</h2>
|
||||
<Badge variant="outline">{roadmap.status}</Badge>
|
||||
{competitorAnalysis && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 cursor-pointer hover:bg-secondary/80 transition-colors"
|
||||
onClick={onViewCompetitorAnalysis}
|
||||
>
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
Competitor Analysis
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">Click to view detailed analysis</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Analyzed {competitorAnalysis.competitors.length} competitors with {' '}
|
||||
{competitorAnalysis.competitors.reduce((sum, c) => sum + c.painPoints.length, 0)} pain points identified
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-xl">{roadmap.vision}</p>
|
||||
</div>
|
||||
@@ -49,9 +72,21 @@ export function RoadmapHeader({ roadmap, onAddFeature, onRefresh }: RoadmapHeade
|
||||
<span className="font-medium">{roadmap.targetAudience.primary}</span>
|
||||
</div>
|
||||
{roadmap.targetAudience.secondary.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
+{roadmap.targetAudience.secondary.length} more personas
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-muted-foreground cursor-help underline decoration-dotted">
|
||||
+{roadmap.targetAudience.secondary.length} more personas
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
<div className="space-y-1">
|
||||
<div className="font-semibold mb-2">Secondary Personas:</div>
|
||||
{roadmap.targetAudience.secondary.map((persona) => (
|
||||
<div key={persona} className="text-sm">• {persona}</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRoadmapStore, loadRoadmap, generateRoadmap, refreshRoadmap } from '../../stores/roadmap-store';
|
||||
import { useRoadmapStore, loadRoadmap, generateRoadmap, refreshRoadmap, stopRoadmap } from '../../stores/roadmap-store';
|
||||
import { useTaskStore } from '../../stores/task-store';
|
||||
import type { RoadmapFeature } from '../../../shared/types';
|
||||
|
||||
/**
|
||||
* Hook to manage roadmap data and loading
|
||||
*
|
||||
* When the projectId changes, this hook:
|
||||
* 1. Loads the new project's roadmap data
|
||||
* 2. Queries the backend to check if generation is running for this project
|
||||
* 3. Restores the generation status UI state accordingly
|
||||
*
|
||||
* NOTE: Generation continues in the background when switching projects.
|
||||
* The loadRoadmap function queries the backend to restore the correct UI state.
|
||||
*/
|
||||
export function useRoadmapData(projectId: string) {
|
||||
const roadmap = useRoadmapStore((state) => state.roadmap);
|
||||
@@ -11,6 +20,9 @@ export function useRoadmapData(projectId: string) {
|
||||
const generationStatus = useRoadmapStore((state) => state.generationStatus);
|
||||
|
||||
useEffect(() => {
|
||||
// Load roadmap data and query generation status for this project
|
||||
// The loadRoadmap function handles checking if generation is running
|
||||
// and restores the UI state accordingly
|
||||
loadRoadmap(projectId);
|
||||
}, [projectId]);
|
||||
|
||||
@@ -26,6 +38,7 @@ export function useRoadmapData(projectId: string) {
|
||||
*/
|
||||
export function useFeatureActions() {
|
||||
const updateFeatureLinkedSpec = useRoadmapStore((state) => state.updateFeatureLinkedSpec);
|
||||
const addTask = useTaskStore((state) => state.addTask);
|
||||
|
||||
const convertFeatureToSpec = async (
|
||||
projectId: string,
|
||||
@@ -35,6 +48,10 @@ export function useFeatureActions() {
|
||||
) => {
|
||||
const result = await window.electronAPI.convertFeatureToSpec(projectId, feature.id);
|
||||
if (result.success && result.data) {
|
||||
// Add the created task to the task store so it appears in the kanban immediately
|
||||
addTask(result.data);
|
||||
|
||||
// Update the roadmap feature with the linked spec
|
||||
updateFeatureLinkedSpec(feature.id, result.data.specId);
|
||||
if (selectedFeature?.id === feature.id) {
|
||||
setSelectedFeature({
|
||||
@@ -70,22 +87,26 @@ export function useRoadmapGeneration(projectId: string) {
|
||||
|
||||
const handleCompetitorDialogAccept = () => {
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId);
|
||||
generateRoadmap(projectId, true); // Enable competitor analysis
|
||||
} else if (pendingAction === 'refresh') {
|
||||
refreshRoadmap(projectId);
|
||||
refreshRoadmap(projectId, true); // Enable competitor analysis
|
||||
}
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
const handleCompetitorDialogDecline = () => {
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId);
|
||||
generateRoadmap(projectId, false); // Disable competitor analysis
|
||||
} else if (pendingAction === 'refresh') {
|
||||
refreshRoadmap(projectId);
|
||||
refreshRoadmap(projectId, false); // Disable competitor analysis
|
||||
}
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await stopRoadmap(projectId);
|
||||
};
|
||||
|
||||
return {
|
||||
pendingAction,
|
||||
showCompetitorDialog,
|
||||
@@ -94,5 +115,6 @@ export function useRoadmapGeneration(projectId: string) {
|
||||
handleRefresh,
|
||||
handleCompetitorDialogAccept,
|
||||
handleCompetitorDialogDecline,
|
||||
handleStop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RoadmapFeature, RoadmapPhase, Roadmap, CompetitorPainPoint } from '../../../shared/types';
|
||||
import type { RoadmapFeature, RoadmapPhase, Roadmap, CompetitorPainPoint, CompetitorAnalysis } from '../../../shared/types';
|
||||
|
||||
export interface RoadmapProps {
|
||||
projectId: string;
|
||||
@@ -32,8 +32,10 @@ export interface FeatureDetailPanelProps {
|
||||
|
||||
export interface RoadmapHeaderProps {
|
||||
roadmap: Roadmap;
|
||||
competitorAnalysis: CompetitorAnalysis | null;
|
||||
onAddFeature: () => void;
|
||||
onRefresh: () => void;
|
||||
onViewCompetitorAnalysis?: () => void;
|
||||
}
|
||||
|
||||
export interface RoadmapEmptyStateProps {
|
||||
|
||||
@@ -171,7 +171,7 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
setSourceUpdateCheck(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for source updates:', err);
|
||||
// Silent fail - user can retry via button
|
||||
} finally {
|
||||
setIsCheckingSourceUpdate(false);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
Search,
|
||||
FolderSearch,
|
||||
Wrench,
|
||||
Info
|
||||
Info,
|
||||
Brain,
|
||||
Cpu
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible';
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry } from '../../../shared/types';
|
||||
import type { Task, TaskLogs, TaskLogPhase, TaskPhaseLog, TaskLogEntry, TaskMetadata } from '../../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig, ThinkingLevel, ModelTypeShort } from '../../../shared/types/settings';
|
||||
|
||||
interface TaskLogsProps {
|
||||
task: Task;
|
||||
@@ -51,6 +54,60 @@ const PHASE_COLORS: Record<TaskLogPhase, string> = {
|
||||
validation: 'text-purple-500 bg-purple-500/10 border-purple-500/30'
|
||||
};
|
||||
|
||||
// Map log phases to config phase keys
|
||||
// Note: 'planning' log phase covers both spec creation and implementation planning
|
||||
const LOG_PHASE_TO_CONFIG_PHASE: Record<TaskLogPhase, keyof PhaseModelConfig> = {
|
||||
planning: 'spec', // Planning log phase primarily shows spec creation
|
||||
coding: 'coding',
|
||||
validation: 'qa'
|
||||
};
|
||||
|
||||
// Short labels for models
|
||||
const MODEL_SHORT_LABELS: Record<ModelTypeShort, string> = {
|
||||
opus: 'Opus',
|
||||
sonnet: 'Sonnet',
|
||||
haiku: 'Haiku'
|
||||
};
|
||||
|
||||
// Short labels for thinking levels
|
||||
const THINKING_SHORT_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: 'None',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra'
|
||||
};
|
||||
|
||||
// Helper to get model and thinking info for a log phase
|
||||
function getPhaseConfig(
|
||||
metadata: TaskMetadata | undefined,
|
||||
logPhase: TaskLogPhase
|
||||
): { model: string; thinking: string } | null {
|
||||
if (!metadata) return null;
|
||||
|
||||
const configPhase = LOG_PHASE_TO_CONFIG_PHASE[logPhase];
|
||||
|
||||
// Auto profile with per-phase config
|
||||
if (metadata.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
|
||||
const model = metadata.phaseModels[configPhase];
|
||||
const thinking = metadata.phaseThinking[configPhase];
|
||||
return {
|
||||
model: MODEL_SHORT_LABELS[model] || model,
|
||||
thinking: THINKING_SHORT_LABELS[thinking] || thinking
|
||||
};
|
||||
}
|
||||
|
||||
// Non-auto profile with single model/thinking
|
||||
if (metadata.model && metadata.thinkingLevel) {
|
||||
return {
|
||||
model: MODEL_SHORT_LABELS[metadata.model] || metadata.model,
|
||||
thinking: THINKING_SHORT_LABELS[metadata.thinkingLevel] || metadata.thinkingLevel
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TaskLogs({
|
||||
task,
|
||||
phaseLogs,
|
||||
@@ -84,6 +141,7 @@ export function TaskLogs({
|
||||
isExpanded={expandedPhases.has(phase)}
|
||||
onToggle={() => onTogglePhase(phase)}
|
||||
isTaskStuck={isStuck}
|
||||
phaseConfig={getPhaseConfig(task.metadata, phase)}
|
||||
/>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
@@ -113,9 +171,10 @@ interface PhaseLogSectionProps {
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
isTaskStuck?: boolean;
|
||||
phaseConfig?: { model: string; thinking: string } | null;
|
||||
}
|
||||
|
||||
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }: PhaseLogSectionProps) {
|
||||
function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck, phaseConfig }: PhaseLogSectionProps) {
|
||||
const Icon = PHASE_ICONS[phase];
|
||||
const status = phaseLog?.status || 'pending';
|
||||
const hasEntries = (phaseLog?.entries.length || 0) > 0;
|
||||
@@ -190,7 +249,23 @@ function PhaseLogSection({ phase, phaseLog, isExpanded, onToggle, isTaskStuck }:
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model and thinking level indicator */}
|
||||
{phaseConfig && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-0.5" title={`Model: ${phaseConfig.model}`}>
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{phaseConfig.model}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<div className="flex items-center gap-0.5" title={`Thinking: ${phaseConfig.thinking}`}>
|
||||
<Brain className="h-3 w-3" />
|
||||
<span>{phaseConfig.thinking}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function TaskReview({
|
||||
onStageOnlyChange={onStageOnlyChange}
|
||||
onMerge={onMerge}
|
||||
/>
|
||||
) : task.stagedInMainProject ? (
|
||||
) : task.stagedInMainProject && !stagedSuccess ? (
|
||||
<StagedInProjectMessage
|
||||
task={task}
|
||||
projectPath={stagedProjectPath}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||
|
||||
const selectedProject = useProjectStore((state) => state.getSelectedProject());
|
||||
const isRunning = task.status === 'in_progress';
|
||||
const isRunning = task.status === 'in_progress' || task.status === 'ai_review';
|
||||
const needsReview = task.status === 'human_review';
|
||||
const executionPhase = task.executionProgress?.phase;
|
||||
const hasActiveExecution = executionPhase && executionPhase !== 'idle' && executionPhase !== 'complete' && executionPhase !== 'failed';
|
||||
|
||||
@@ -48,34 +48,93 @@ export function useIpcListeners(): void {
|
||||
);
|
||||
|
||||
// Roadmap event listeners
|
||||
const setGenerationStatus = useRoadmapStore.getState().setGenerationStatus;
|
||||
const setRoadmap = useRoadmapStore.getState().setRoadmap;
|
||||
// Helper to check if event is for the currently viewed project
|
||||
const isCurrentProject = (eventProjectId: string): boolean => {
|
||||
const currentProjectId = useRoadmapStore.getState().currentProjectId;
|
||||
return currentProjectId === eventProjectId;
|
||||
};
|
||||
|
||||
const cleanupRoadmapProgress = window.electronAPI.onRoadmapProgress(
|
||||
(_projectId: string, status: RoadmapGenerationStatus) => {
|
||||
setGenerationStatus(status);
|
||||
(projectId: string, status: RoadmapGenerationStatus) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Progress update:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
phase: status.phase,
|
||||
progress: status.progress,
|
||||
message: status.message
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus(status);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapComplete = window.electronAPI.onRoadmapComplete(
|
||||
(_projectId: string, roadmap: Roadmap) => {
|
||||
setRoadmap(roadmap);
|
||||
setGenerationStatus({
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Roadmap ready'
|
||||
});
|
||||
(projectId: string, roadmap: Roadmap) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Generation complete:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
featuresCount: roadmap.features?.length || 0,
|
||||
phasesCount: roadmap.phases?.length || 0
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setRoadmap(roadmap);
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
message: 'Roadmap ready'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapError = window.electronAPI.onRoadmapError(
|
||||
(_projectId: string, error: string) => {
|
||||
setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
message: 'Generation failed',
|
||||
error
|
||||
});
|
||||
(projectId: string, error: string) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Roadmap] Error received:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId,
|
||||
error
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
message: 'Generation failed',
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const cleanupRoadmapStopped = window.electronAPI.onRoadmapStopped(
|
||||
(projectId: string) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Generation stopped:', {
|
||||
projectId,
|
||||
currentProjectId: useRoadmapStore.getState().currentProjectId
|
||||
});
|
||||
}
|
||||
// Only update if this is for the currently viewed project
|
||||
if (isCurrentProject(projectId)) {
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -117,6 +176,7 @@ export function useIpcListeners(): void {
|
||||
cleanupRoadmapProgress();
|
||||
cleanupRoadmapComplete();
|
||||
cleanupRoadmapError();
|
||||
cleanupRoadmapStopped();
|
||||
cleanupRateLimit();
|
||||
cleanupSDKRateLimit();
|
||||
};
|
||||
|
||||
@@ -53,6 +53,11 @@ const browserMockAPI: ElectronAPI = {
|
||||
data: null
|
||||
}),
|
||||
|
||||
getRoadmapStatus: async () => ({
|
||||
success: true,
|
||||
data: { isRunning: false }
|
||||
}),
|
||||
|
||||
saveRoadmap: async () => ({
|
||||
success: true
|
||||
}),
|
||||
@@ -83,10 +88,13 @@ const browserMockAPI: ElectronAPI = {
|
||||
}
|
||||
}),
|
||||
|
||||
stopRoadmap: async () => ({ success: true }),
|
||||
|
||||
// Roadmap Event Listeners
|
||||
onRoadmapProgress: () => () => {},
|
||||
onRoadmapComplete: () => () => {},
|
||||
onRoadmapError: () => () => {},
|
||||
onRoadmapStopped: () => () => {},
|
||||
// Context Operations
|
||||
...contextMock,
|
||||
|
||||
|
||||
@@ -78,6 +78,11 @@ export const insightsMock = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateInsightsModelConfig: async (_projectId: string, _sessionId: string, _modelConfig: unknown) => {
|
||||
console.warn('[Browser Mock] updateInsightsModelConfig called');
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
sendInsightsMessage: () => {
|
||||
console.warn('[Browser Mock] sendInsightsMessage called');
|
||||
},
|
||||
|
||||
@@ -117,6 +117,11 @@ export const integrationMock = {
|
||||
console.warn('[Browser Mock] investigateGitHubIssue called');
|
||||
},
|
||||
|
||||
getIssueComments: async () => ({
|
||||
success: true,
|
||||
data: []
|
||||
}),
|
||||
|
||||
importGitHubIssues: async () => ({
|
||||
success: false,
|
||||
error: 'Not available in browser mock'
|
||||
@@ -173,5 +178,15 @@ export const integrationMock = {
|
||||
{ fullName: 'user/private-repo', description: 'A private repository', isPrivate: true }
|
||||
]
|
||||
}
|
||||
}),
|
||||
|
||||
detectGitHubRepo: async () => ({
|
||||
success: true,
|
||||
data: 'user/example-repo'
|
||||
}),
|
||||
|
||||
getGitHubBranches: async () => ({
|
||||
success: true,
|
||||
data: ['main', 'develop', 'feature/example']
|
||||
})
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ export async function checkGitHubConnection(projectId: string): Promise<GitHubSy
|
||||
}
|
||||
}
|
||||
|
||||
export function investigateGitHubIssue(projectId: string, issueNumber: number): void {
|
||||
export function investigateGitHubIssue(projectId: string, issueNumber: number, selectedCommentIds?: number[]): void {
|
||||
const store = useGitHubStore.getState();
|
||||
store.setInvestigationStatus({
|
||||
phase: 'fetching',
|
||||
@@ -157,7 +157,7 @@ export function investigateGitHubIssue(projectId: string, issueNumber: number):
|
||||
});
|
||||
store.setInvestigationResult(null);
|
||||
|
||||
window.electronAPI.investigateGitHubIssue(projectId, issueNumber);
|
||||
window.electronAPI.investigateGitHubIssue(projectId, issueNumber, selectedCommentIds);
|
||||
}
|
||||
|
||||
export async function importGitHubIssues(
|
||||
|
||||
@@ -337,6 +337,18 @@ export async function loadIdeation(projectId: string): Promise<void> {
|
||||
export function generateIdeation(projectId: string): void {
|
||||
const store = useIdeationStore.getState();
|
||||
const config = store.config;
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Starting generation:', {
|
||||
projectId,
|
||||
enabledTypes: config.enabledTypes,
|
||||
includeRoadmapContext: config.includeRoadmapContext,
|
||||
includeKanbanContext: config.includeKanbanContext,
|
||||
maxIdeasPerType: config.maxIdeasPerType
|
||||
});
|
||||
}
|
||||
|
||||
store.clearLogs();
|
||||
store.clearSession(); // Clear existing session for fresh generation
|
||||
store.initializeTypeStates(config.enabledTypes);
|
||||
@@ -351,15 +363,35 @@ export function generateIdeation(projectId: string): void {
|
||||
|
||||
export async function stopIdeation(projectId: string): Promise<boolean> {
|
||||
const store = useIdeationStore.getState();
|
||||
const result = await window.electronAPI.stopIdeation(projectId);
|
||||
if (result.success) {
|
||||
store.addLog('Ideation generation stopped');
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Stop requested:', { projectId });
|
||||
}
|
||||
|
||||
// Always update UI state to 'idle' when user requests stop, regardless of backend response
|
||||
// This prevents the UI from getting stuck in "generating" state if the process already ended
|
||||
store.addLog('Stopping ideation generation...');
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
|
||||
const result = await window.electronAPI.stopIdeation(projectId);
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Stop result:', { projectId, success: result.success });
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Backend couldn't find/stop the process (likely already finished/crashed)
|
||||
store.addLog('Process already stopped');
|
||||
} else {
|
||||
store.addLog('Ideation generation stopped');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}
|
||||
|
||||
@@ -528,6 +560,15 @@ export function setupIdeationListeners(): () => void {
|
||||
|
||||
// Listen for progress updates
|
||||
const unsubProgress = window.electronAPI.onIdeationProgress((_projectId, status) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Progress update:', {
|
||||
projectId: _projectId,
|
||||
phase: status.phase,
|
||||
progress: status.progress,
|
||||
message: status.message
|
||||
});
|
||||
}
|
||||
store().setGenerationStatus(status);
|
||||
});
|
||||
|
||||
@@ -539,6 +580,16 @@ export function setupIdeationListeners(): () => void {
|
||||
// Listen for individual ideation type completion (streaming)
|
||||
const unsubTypeComplete = window.electronAPI.onIdeationTypeComplete(
|
||||
(_projectId, ideationType, ideas) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Type completed:', {
|
||||
projectId: _projectId,
|
||||
ideationType,
|
||||
ideasCount: ideas.length,
|
||||
ideas: ideas.map(i => ({ id: i.id, title: i.title, type: i.type }))
|
||||
});
|
||||
}
|
||||
|
||||
store().addIdeasForType(ideationType, ideas);
|
||||
store().addLog(`✓ ${ideationType} completed with ${ideas.length} ideas`);
|
||||
|
||||
@@ -564,6 +615,11 @@ export function setupIdeationListeners(): () => void {
|
||||
// Listen for individual ideation type failure
|
||||
const unsubTypeFailed = window.electronAPI.onIdeationTypeFailed(
|
||||
(_projectId, ideationType) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Ideation] Type failed:', { projectId: _projectId, ideationType });
|
||||
}
|
||||
|
||||
store().setTypeState(ideationType as IdeationType, 'failed');
|
||||
store().addLog(`✗ ${ideationType} failed`);
|
||||
}
|
||||
@@ -571,6 +627,18 @@ export function setupIdeationListeners(): () => void {
|
||||
|
||||
// Listen for completion (final session with all data)
|
||||
const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Generation complete:', {
|
||||
projectId: _projectId,
|
||||
totalIdeas: session.ideas.length,
|
||||
ideaTypes: session.ideas.reduce((acc, idea) => {
|
||||
acc[idea.type] = (acc[idea.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
});
|
||||
}
|
||||
|
||||
// Final session replaces the partial one with complete data
|
||||
store().setSession(session);
|
||||
store().setGenerationStatus({
|
||||
@@ -583,6 +651,11 @@ export function setupIdeationListeners(): () => void {
|
||||
|
||||
// Listen for errors
|
||||
const unsubError = window.electronAPI.onIdeationError((_projectId, error) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Ideation] Error received:', { projectId: _projectId, error });
|
||||
}
|
||||
|
||||
store().setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk,
|
||||
InsightsToolUsage,
|
||||
InsightsModelConfig,
|
||||
TaskMetadata,
|
||||
Task
|
||||
} from '../../shared/types';
|
||||
@@ -221,8 +222,9 @@ export async function loadInsightsSession(projectId: string): Promise<void> {
|
||||
await loadInsightsSessions(projectId);
|
||||
}
|
||||
|
||||
export function sendMessage(projectId: string, message: string): void {
|
||||
export function sendMessage(projectId: string, message: string, modelConfig?: InsightsModelConfig): void {
|
||||
const store = useInsightsStore.getState();
|
||||
const session = store.session;
|
||||
|
||||
// Add user message to session
|
||||
const userMessage: InsightsChatMessage = {
|
||||
@@ -242,8 +244,11 @@ export function sendMessage(projectId: string, message: string): void {
|
||||
message: 'Processing your message...'
|
||||
});
|
||||
|
||||
// Use provided modelConfig, or fall back to session's config
|
||||
const configToUse = modelConfig || session?.modelConfig;
|
||||
|
||||
// Send to main process
|
||||
window.electronAPI.sendInsightsMessage(projectId, message);
|
||||
window.electronAPI.sendInsightsMessage(projectId, message, configToUse);
|
||||
}
|
||||
|
||||
export async function clearSession(projectId: string): Promise<void> {
|
||||
@@ -296,6 +301,25 @@ export async function renameSession(projectId: string, sessionId: string, newTit
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateModelConfig(projectId: string, sessionId: string, modelConfig: InsightsModelConfig): Promise<boolean> {
|
||||
const result = await window.electronAPI.updateInsightsModelConfig(projectId, sessionId, modelConfig);
|
||||
if (result.success) {
|
||||
// Update local session state
|
||||
const store = useInsightsStore.getState();
|
||||
if (store.session?.id === sessionId) {
|
||||
store.setSession({
|
||||
...store.session,
|
||||
modelConfig,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
}
|
||||
// Reload sessions list to reflect the change
|
||||
await loadInsightsSessions(projectId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createTaskFromSuggestion(
|
||||
projectId: string,
|
||||
title: string,
|
||||
|
||||
@@ -12,11 +12,13 @@ interface RoadmapState {
|
||||
roadmap: Roadmap | null;
|
||||
competitorAnalysis: CompetitorAnalysis | null;
|
||||
generationStatus: RoadmapGenerationStatus;
|
||||
currentProjectId: string | null; // Track which project we're viewing/generating for
|
||||
|
||||
// Actions
|
||||
setRoadmap: (roadmap: Roadmap | null) => void;
|
||||
setCompetitorAnalysis: (analysis: CompetitorAnalysis | null) => void;
|
||||
setGenerationStatus: (status: RoadmapGenerationStatus) => void;
|
||||
setCurrentProjectId: (projectId: string | null) => void;
|
||||
updateFeatureStatus: (featureId: string, status: RoadmapFeatureStatus) => void;
|
||||
updateFeatureLinkedSpec: (featureId: string, specId: string) => void;
|
||||
clearRoadmap: () => void;
|
||||
@@ -37,6 +39,7 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
roadmap: null,
|
||||
competitorAnalysis: null,
|
||||
generationStatus: initialGenerationStatus,
|
||||
currentProjectId: null,
|
||||
|
||||
// Actions
|
||||
setRoadmap: (roadmap) => set({ roadmap }),
|
||||
@@ -45,6 +48,8 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
|
||||
setGenerationStatus: (status) => set({ generationStatus: status }),
|
||||
|
||||
setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }),
|
||||
|
||||
updateFeatureStatus: (featureId, status) =>
|
||||
set((state) => {
|
||||
if (!state.roadmap) return state;
|
||||
@@ -85,7 +90,8 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
set({
|
||||
roadmap: null,
|
||||
competitorAnalysis: null,
|
||||
generationStatus: initialGenerationStatus
|
||||
generationStatus: initialGenerationStatus,
|
||||
currentProjectId: null
|
||||
}),
|
||||
|
||||
// Reorder features within a phase
|
||||
@@ -159,9 +165,34 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
|
||||
|
||||
// Helper functions for loading roadmap
|
||||
export async function loadRoadmap(projectId: string): Promise<void> {
|
||||
const store = useRoadmapStore.getState();
|
||||
|
||||
// Always set current project ID first - this ensures event handlers
|
||||
// only process events for the currently viewed project
|
||||
store.setCurrentProjectId(projectId);
|
||||
|
||||
// Query if roadmap generation is currently running for this project
|
||||
// This restores the generation status when switching back to a project
|
||||
const statusResult = await window.electronAPI.getRoadmapStatus(projectId);
|
||||
if (statusResult.success && statusResult.data?.isRunning) {
|
||||
// Generation is running - restore the UI state to show progress
|
||||
// The actual progress will be updated by incoming events
|
||||
store.setGenerationStatus({
|
||||
phase: 'analyzing',
|
||||
progress: 0,
|
||||
message: 'Roadmap generation in progress...'
|
||||
});
|
||||
} else {
|
||||
// Generation is not running - reset to idle
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getRoadmap(projectId);
|
||||
if (result.success && result.data) {
|
||||
const store = useRoadmapStore.getState();
|
||||
store.setRoadmap(result.data);
|
||||
// Extract and set competitor analysis separately if present
|
||||
if (result.data.competitorAnalysis) {
|
||||
@@ -170,28 +201,63 @@ export async function loadRoadmap(projectId: string): Promise<void> {
|
||||
store.setCompetitorAnalysis(null);
|
||||
}
|
||||
} else {
|
||||
const store = useRoadmapStore.getState();
|
||||
store.setRoadmap(null);
|
||||
store.setCompetitorAnalysis(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function generateRoadmap(projectId: string): void {
|
||||
export function generateRoadmap(projectId: string, enableCompetitorAnalysis?: boolean): void {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Starting generation:', { projectId, enableCompetitorAnalysis });
|
||||
}
|
||||
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'analyzing',
|
||||
progress: 0,
|
||||
message: 'Starting roadmap generation...'
|
||||
});
|
||||
window.electronAPI.generateRoadmap(projectId);
|
||||
window.electronAPI.generateRoadmap(projectId, enableCompetitorAnalysis);
|
||||
}
|
||||
|
||||
export function refreshRoadmap(projectId: string): void {
|
||||
export function refreshRoadmap(projectId: string, enableCompetitorAnalysis?: boolean): void {
|
||||
useRoadmapStore.getState().setGenerationStatus({
|
||||
phase: 'analyzing',
|
||||
progress: 0,
|
||||
message: 'Refreshing roadmap...'
|
||||
});
|
||||
window.electronAPI.refreshRoadmap(projectId);
|
||||
window.electronAPI.refreshRoadmap(projectId, enableCompetitorAnalysis);
|
||||
}
|
||||
|
||||
export async function stopRoadmap(projectId: string): Promise<boolean> {
|
||||
const store = useRoadmapStore.getState();
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Stop requested:', { projectId });
|
||||
}
|
||||
|
||||
// Always update UI state to 'idle' when user requests stop, regardless of backend response
|
||||
// This prevents the UI from getting stuck in "generating" state if the process already ended
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
message: 'Generation stopped'
|
||||
});
|
||||
|
||||
const result = await window.electronAPI.stopRoadmap(projectId);
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Roadmap] Stop result:', { projectId, success: result.success });
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Backend couldn't find/stop the process (likely already finished/crashed)
|
||||
console.log('[Roadmap] Process already stopped');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}
|
||||
|
||||
// Selectors
|
||||
|
||||
@@ -514,6 +514,19 @@ export function isDraftEmpty(draft: TaskDraft | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GitHub Issue Linking Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find a task by GitHub issue number
|
||||
* Used to check if a task already exists for a GitHub issue
|
||||
*/
|
||||
export function getTaskByGitHubIssue(issueNumber: number): Task | undefined {
|
||||
const store = useTaskStore.getState();
|
||||
return store.tasks.find(t => t.metadata?.githubIssueNumber === issueNumber);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Task State Detection Helpers
|
||||
// ============================================
|
||||
|
||||
@@ -25,8 +25,8 @@ export const DEFAULT_APP_SETTINGS = {
|
||||
// Global API keys (used as defaults for all projects)
|
||||
globalClaudeOAuthToken: undefined as string | undefined,
|
||||
globalOpenAIApiKey: undefined as string | undefined,
|
||||
// Selected agent profile - defaults to 'balanced' for good speed/quality balance
|
||||
selectedAgentProfile: 'balanced',
|
||||
// Selected agent profile - defaults to 'auto' for per-phase optimized model selection
|
||||
selectedAgentProfile: 'auto',
|
||||
// Changelog preferences (persisted between sessions)
|
||||
changelogFormat: 'keep-a-changelog' as const,
|
||||
changelogAudience: 'user-facing' as const,
|
||||
|
||||
@@ -116,10 +116,12 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// Roadmap operations
|
||||
ROADMAP_GET: 'roadmap:get',
|
||||
ROADMAP_GET_STATUS: 'roadmap:getStatus',
|
||||
ROADMAP_SAVE: 'roadmap:save',
|
||||
ROADMAP_GENERATE: 'roadmap:generate',
|
||||
ROADMAP_GENERATE_WITH_COMPETITOR: 'roadmap:generateWithCompetitor',
|
||||
ROADMAP_REFRESH: 'roadmap:refresh',
|
||||
ROADMAP_STOP: 'roadmap:stop',
|
||||
ROADMAP_UPDATE_FEATURE: 'roadmap:updateFeature',
|
||||
ROADMAP_CONVERT_TO_SPEC: 'roadmap:convertToSpec',
|
||||
|
||||
@@ -127,6 +129,7 @@ export const IPC_CHANNELS = {
|
||||
ROADMAP_PROGRESS: 'roadmap:progress',
|
||||
ROADMAP_COMPLETE: 'roadmap:complete',
|
||||
ROADMAP_ERROR: 'roadmap:error',
|
||||
ROADMAP_STOPPED: 'roadmap:stopped',
|
||||
|
||||
// Context operations
|
||||
CONTEXT_GET: 'context:get',
|
||||
@@ -174,6 +177,7 @@ export const IPC_CHANNELS = {
|
||||
GITHUB_GET_REPOSITORIES: 'github:getRepositories',
|
||||
GITHUB_GET_ISSUES: 'github:getIssues',
|
||||
GITHUB_GET_ISSUE: 'github:getIssue',
|
||||
GITHUB_GET_ISSUE_COMMENTS: 'github:getIssueComments',
|
||||
GITHUB_CHECK_CONNECTION: 'github:checkConnection',
|
||||
GITHUB_INVESTIGATE_ISSUE: 'github:investigateIssue',
|
||||
GITHUB_IMPORT_ISSUES: 'github:importIssues',
|
||||
@@ -186,6 +190,8 @@ export const IPC_CHANNELS = {
|
||||
GITHUB_GET_TOKEN: 'github:getToken',
|
||||
GITHUB_GET_USER: 'github:getUser',
|
||||
GITHUB_LIST_USER_REPOS: 'github:listUserRepos',
|
||||
GITHUB_DETECT_REPO: 'github:detectRepo',
|
||||
GITHUB_GET_BRANCHES: 'github:getBranches',
|
||||
|
||||
// GitHub events (main -> renderer)
|
||||
GITHUB_INVESTIGATION_PROGRESS: 'github:investigationProgress',
|
||||
@@ -245,6 +251,7 @@ export const IPC_CHANNELS = {
|
||||
INSIGHTS_SWITCH_SESSION: 'insights:switchSession',
|
||||
INSIGHTS_DELETE_SESSION: 'insights:deleteSession',
|
||||
INSIGHTS_RENAME_SESSION: 'insights:renameSession',
|
||||
INSIGHTS_UPDATE_MODEL_CONFIG: 'insights:updateModelConfig',
|
||||
|
||||
// Insights events (main -> renderer)
|
||||
INSIGHTS_STREAM_CHUNK: 'insights:streamChunk',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Claude models, thinking levels, memory backends, and agent profiles
|
||||
*/
|
||||
|
||||
import type { AgentProfile } from '../types/settings';
|
||||
import type { AgentProfile, PhaseModelConfig } from '../types/settings';
|
||||
|
||||
// ============================================
|
||||
// Available Models
|
||||
@@ -15,6 +15,22 @@ export const AVAILABLE_MODELS = [
|
||||
{ value: 'haiku', label: 'Claude Haiku 4.5' }
|
||||
] as const;
|
||||
|
||||
// Maps model shorthand to actual Claude model IDs
|
||||
export const MODEL_ID_MAP: Record<string, string> = {
|
||||
opus: 'claude-opus-4-5-20251101',
|
||||
sonnet: 'claude-sonnet-4-5-20250929',
|
||||
haiku: 'claude-haiku-4-5-20251001'
|
||||
} as const;
|
||||
|
||||
// Maps thinking levels to budget tokens (null = no extended thinking)
|
||||
export const THINKING_BUDGET_MAP: Record<string, number | null> = {
|
||||
none: null,
|
||||
low: 1024,
|
||||
medium: 4096,
|
||||
high: 16384,
|
||||
ultrathink: 65536
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// Thinking Levels
|
||||
// ============================================
|
||||
@@ -32,8 +48,36 @@ export const THINKING_LEVELS = [
|
||||
// Agent Profiles
|
||||
// ============================================
|
||||
|
||||
// Default phase model configuration for Auto profile
|
||||
// Optimized for each phase: fast discovery, quality planning, balanced coding, thorough QA
|
||||
export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
|
||||
spec: 'sonnet', // Good quality specs without being too slow
|
||||
planning: 'opus', // Complex architecture decisions benefit from Opus
|
||||
coding: 'sonnet', // Good balance of speed and quality for implementation
|
||||
qa: 'sonnet' // Thorough but not overly slow QA
|
||||
};
|
||||
|
||||
// Default phase thinking configuration for Auto profile
|
||||
export const DEFAULT_PHASE_THINKING: import('../types/settings').PhaseThinkingConfig = {
|
||||
spec: 'medium', // Moderate thinking for spec creation
|
||||
planning: 'high', // Deep thinking for planning complex features
|
||||
coding: 'medium', // Standard thinking for coding
|
||||
qa: 'high' // Thorough analysis for QA review
|
||||
};
|
||||
|
||||
// Default agent profiles for preset model/thinking configurations
|
||||
export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto (Optimized)',
|
||||
description: 'Uses different models per phase for optimal speed & quality',
|
||||
model: 'sonnet', // Fallback/default model
|
||||
thinkingLevel: 'medium',
|
||||
icon: 'Sparkles',
|
||||
isAutoProfile: true,
|
||||
phaseModels: DEFAULT_PHASE_MODELS,
|
||||
phaseThinking: DEFAULT_PHASE_THINKING
|
||||
},
|
||||
{
|
||||
id: 'complex',
|
||||
name: 'Complex Tasks',
|
||||
|
||||
@@ -154,6 +154,16 @@ export interface IdeationSummary {
|
||||
// Insights Chat Types
|
||||
// ============================================
|
||||
|
||||
import type { ThinkingLevel } from './settings';
|
||||
import type { ModelType } from './task';
|
||||
|
||||
// Model configuration for insights sessions
|
||||
export interface InsightsModelConfig {
|
||||
profileId: string; // 'complex' | 'balanced' | 'quick' | 'custom'
|
||||
model: ModelType; // 'haiku' | 'sonnet' | 'opus'
|
||||
thinkingLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export type InsightsChatRole = 'user' | 'assistant';
|
||||
|
||||
// Tool usage record for showing what tools the AI used
|
||||
@@ -183,6 +193,7 @@ export interface InsightsSession {
|
||||
projectId: string;
|
||||
title?: string; // Auto-generated from first message or user-set
|
||||
messages: InsightsChatMessage[];
|
||||
modelConfig?: InsightsModelConfig; // Per-session model configuration
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -193,6 +204,7 @@ export interface InsightsSessionSummary {
|
||||
projectId: string;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
modelConfig?: InsightsModelConfig; // For displaying model indicator in sidebar
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ import type {
|
||||
InsightsSession,
|
||||
InsightsSessionSummary,
|
||||
InsightsChatStatus,
|
||||
InsightsStreamChunk
|
||||
InsightsStreamChunk,
|
||||
InsightsModelConfig
|
||||
} from './insights';
|
||||
import type {
|
||||
Roadmap,
|
||||
@@ -236,9 +237,12 @@ export interface ElectronAPI {
|
||||
|
||||
// Roadmap operations
|
||||
getRoadmap: (projectId: string) => Promise<IPCResult<Roadmap | null>>;
|
||||
getRoadmapStatus: (projectId: string) => Promise<IPCResult<{ isRunning: boolean }>>;
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap) => Promise<IPCResult>;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void; updateFeatureStatus: (
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean) => void;
|
||||
stopRoadmap: (projectId: string) => Promise<IPCResult>;
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
status: RoadmapFeatureStatus
|
||||
@@ -258,6 +262,9 @@ export interface ElectronAPI {
|
||||
onRoadmapError: (
|
||||
callback: (projectId: string, error: string) => void
|
||||
) => () => void;
|
||||
onRoadmapStopped: (
|
||||
callback: (projectId: string) => void
|
||||
) => () => void;
|
||||
|
||||
// Context operations
|
||||
getProjectContext: (projectId: string) => Promise<IPCResult<ProjectContextData>>;
|
||||
@@ -299,7 +306,8 @@ export interface ElectronAPI {
|
||||
getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise<IPCResult<GitHubIssue[]>>;
|
||||
getGitHubIssue: (projectId: string, issueNumber: number) => Promise<IPCResult<GitHubIssue>>;
|
||||
checkGitHubConnection: (projectId: string) => Promise<IPCResult<GitHubSyncStatus>>;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number) => void;
|
||||
investigateGitHubIssue: (projectId: string, issueNumber: number, selectedCommentIds?: number[]) => void;
|
||||
getIssueComments: (projectId: string, issueNumber: number) => Promise<IPCResult<Array<{ id: number; body: string; user: { login: string; avatar_url?: string }; created_at: string; updated_at: string }>>>;
|
||||
importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise<IPCResult<GitHubImportResult>>;
|
||||
createGitHubRelease: (
|
||||
projectId: string,
|
||||
@@ -315,6 +323,8 @@ export interface ElectronAPI {
|
||||
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
detectGitHubRepo: (projectPath: string) => Promise<IPCResult<string>>;
|
||||
getGitHubBranches: (repo: string, token: string) => Promise<IPCResult<string[]>>;
|
||||
|
||||
// GitHub event listeners
|
||||
onGitHubInvestigationProgress: (
|
||||
@@ -455,7 +465,7 @@ export interface ElectronAPI {
|
||||
|
||||
// Insights operations
|
||||
getInsightsSession: (projectId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
sendInsightsMessage: (projectId: string, message: string) => void;
|
||||
sendInsightsMessage: (projectId: string, message: string, modelConfig?: InsightsModelConfig) => void;
|
||||
clearInsightsSession: (projectId: string) => Promise<IPCResult>;
|
||||
createTaskFromInsights: (
|
||||
projectId: string,
|
||||
@@ -468,6 +478,7 @@ export interface ElectronAPI {
|
||||
switchInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult<InsightsSession | null>>;
|
||||
deleteInsightsSession: (projectId: string, sessionId: string) => Promise<IPCResult>;
|
||||
renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise<IPCResult>;
|
||||
updateInsightsModelConfig: (projectId: string, sessionId: string, modelConfig: InsightsModelConfig) => Promise<IPCResult>;
|
||||
|
||||
// Insights event listeners
|
||||
onInsightsStreamChunk: (
|
||||
@@ -507,5 +518,6 @@ export interface ElectronAPI {
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
DEBUG: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,38 @@ import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './
|
||||
// Thinking level for Claude model (budget token allocation)
|
||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
|
||||
// Model type shorthand
|
||||
export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus';
|
||||
|
||||
// Phase-based model configuration for Auto profile
|
||||
// Each phase can use a different model optimized for that task type
|
||||
export interface PhaseModelConfig {
|
||||
spec: ModelTypeShort; // Spec creation (discovery, requirements, context)
|
||||
planning: ModelTypeShort; // Implementation planning
|
||||
coding: ModelTypeShort; // Actual coding implementation
|
||||
qa: ModelTypeShort; // QA review and fixing
|
||||
}
|
||||
|
||||
// Thinking level configuration per phase
|
||||
export interface PhaseThinkingConfig {
|
||||
spec: ThinkingLevel;
|
||||
planning: ThinkingLevel;
|
||||
coding: ThinkingLevel;
|
||||
qa: ThinkingLevel;
|
||||
}
|
||||
|
||||
// Agent profile for preset model/thinking configurations
|
||||
export interface AgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
model: 'haiku' | 'sonnet' | 'opus';
|
||||
model: ModelTypeShort;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
icon?: string; // Lucide icon name
|
||||
// Auto profile specific - per-phase configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: PhaseModelConfig;
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Task-related types
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel } from './settings';
|
||||
import type { ThinkingLevel, PhaseModelConfig, PhaseThinkingConfig } from './settings';
|
||||
|
||||
export type TaskStatus = 'backlog' | 'in_progress' | 'ai_review' | 'human_review' | 'done';
|
||||
|
||||
@@ -136,8 +136,12 @@ export interface TaskDraft {
|
||||
priority: TaskPriority | '';
|
||||
complexity: TaskComplexity | '';
|
||||
impact: TaskImpact | '';
|
||||
profileId?: string; // Agent profile ID ('auto', 'complex', 'balanced', 'quick', 'custom')
|
||||
model: ModelType | '';
|
||||
thinkingLevel: ThinkingLevel | '';
|
||||
// Auto profile - per-phase configuration
|
||||
phaseModels?: PhaseModelConfig;
|
||||
phaseThinking?: PhaseThinkingConfig;
|
||||
images: ImageAttachment[];
|
||||
referencedFiles: ReferencedFile[];
|
||||
requireReviewBeforeCoding?: boolean;
|
||||
@@ -209,8 +213,12 @@ export interface TaskMetadata {
|
||||
requireReviewBeforeCoding?: boolean; // Require human review of spec/plan before coding starts
|
||||
|
||||
// Agent configuration (from agent profile or manual selection)
|
||||
model?: ModelType; // Claude model to use (haiku, sonnet, opus)
|
||||
model?: ModelType; // Claude model to use (haiku, sonnet, opus) - used when not auto profile
|
||||
thinkingLevel?: ThinkingLevel; // Thinking budget level (none, low, medium, high, ultrathink)
|
||||
// Auto profile - per-phase model configuration
|
||||
isAutoProfile?: boolean; // True when using Auto (Optimized) profile
|
||||
phaseModels?: PhaseModelConfig; // Per-phase model configuration
|
||||
phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration
|
||||
|
||||
// Archive status
|
||||
archivedAt?: string; // ISO date when task was archived
|
||||
@@ -239,9 +247,10 @@ export interface Task {
|
||||
|
||||
// Implementation Plan (from auto-claude)
|
||||
export interface ImplementationPlan {
|
||||
feature: string;
|
||||
feature?: string; // Some plans use 'feature', some use 'title'
|
||||
title?: string; // Alternative to 'feature' for task name
|
||||
workflow_type: string;
|
||||
services_involved: string[];
|
||||
services_involved?: string[];
|
||||
phases: Phase[];
|
||||
final_acceptance: string[];
|
||||
created_at: string;
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
# Auto Claude Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Claude Code OAuth Token (REQUIRED)
|
||||
# Get this by running: claude setup-token
|
||||
CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
# =============================================================================
|
||||
# AUTHENTICATION (REQUIRED - set ONE of the following)
|
||||
# =============================================================================
|
||||
# The framework checks these in order of priority:
|
||||
# 1. CLAUDE_CODE_OAUTH_TOKEN - Original (from `claude setup-token`)
|
||||
# 2. ANTHROPIC_AUTH_TOKEN - For proxies like CCR
|
||||
# 3. ANTHROPIC_API_KEY - Direct Anthropic API key
|
||||
#
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-zcf-x-ccr
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOM API ENDPOINT (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Override the default Anthropic API endpoint. Useful for:
|
||||
# - Local proxies (ccr, litellm)
|
||||
# - API gateways
|
||||
# - Self-hosted Claude instances
|
||||
#
|
||||
# ANTHROPIC_BASE_URL=http://127.0.0.1:3456
|
||||
#
|
||||
# Related settings (usually set together with ANTHROPIC_BASE_URL):
|
||||
# NO_PROXY=127.0.0.1
|
||||
# DISABLE_TELEMETRY=true
|
||||
# DISABLE_COST_WARNINGS=true
|
||||
# API_TIMEOUT_MS=600000
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from linear_updater import (
|
||||
LinearTaskState,
|
||||
is_linear_enabled,
|
||||
@@ -17,6 +17,7 @@ from linear_updater import (
|
||||
linear_task_started,
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
count_subtasks_detailed,
|
||||
@@ -244,8 +245,19 @@ async def run_autonomous_agent(
|
||||
commit_before = get_latest_commit(project_dir)
|
||||
commit_count_before = get_commit_count(project_dir)
|
||||
|
||||
# Create client (fresh context)
|
||||
client = create_client(project_dir, spec_dir, model)
|
||||
# Get the phase-specific model (respects task_metadata.json configuration)
|
||||
# first_run means we're in planning phase, otherwise coding phase
|
||||
current_phase = "planning" if first_run else "coding"
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
|
||||
# Create client (fresh context) with phase-specific model
|
||||
# Coding phase uses no extended thinking for fast iteration
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
max_thinking_tokens=None, # No extended thinking for coding
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
|
||||
@@ -8,7 +8,7 @@ Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from client import create_client
|
||||
from core.client import create_client
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from insight_extractor import extract_session_insights
|
||||
from linear_updater import (
|
||||
linear_subtask_completed,
|
||||
@@ -332,20 +333,40 @@ async def run_agent_session(
|
||||
- "complete" if all subtasks complete
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
debug_section("session", f"Agent Session - {phase.value}")
|
||||
debug(
|
||||
"session",
|
||||
"Starting agent session",
|
||||
spec_dir=str(spec_dir),
|
||||
phase=phase.value,
|
||||
prompt_length=len(message),
|
||||
prompt_preview=message[:200] + "..." if len(message) > 200 else message,
|
||||
)
|
||||
print("Sending prompt to Claude Agent SDK...\n")
|
||||
|
||||
# Get task logger for this spec
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
try:
|
||||
# Send the query
|
||||
debug("session", "Sending query to Claude SDK...")
|
||||
await client.query(message)
|
||||
debug_success("session", "Query sent successfully")
|
||||
|
||||
# Collect response text and show tool use
|
||||
response_text = ""
|
||||
debug("session", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
# Handle AssistantMessage (text and tool use)
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
@@ -366,6 +387,7 @@ async def run_agent_session(
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if hasattr(block, "input") and block.input:
|
||||
@@ -386,6 +408,15 @@ async def run_agent_session(
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input,
|
||||
full_input=str(block.input)[:500]
|
||||
if hasattr(block, "input")
|
||||
else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
@@ -413,6 +444,11 @@ async def run_agent_session(
|
||||
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
result=str(result_content)[:300],
|
||||
)
|
||||
print(f" [BLOCKED] {result_content}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
task_logger.tool_end(
|
||||
@@ -425,6 +461,11 @@ async def run_agent_session(
|
||||
elif is_error:
|
||||
# Show errors (truncated)
|
||||
error_str = str(result_content)[:500]
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool error: {current_tool}",
|
||||
error=error_str[:200],
|
||||
)
|
||||
print(f" [Error] {error_str}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
# Store full error in detail for expandable view
|
||||
@@ -437,6 +478,11 @@ async def run_agent_session(
|
||||
)
|
||||
else:
|
||||
# Tool succeeded
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if verbose:
|
||||
result_str = str(result_content)[:200]
|
||||
print(f" [Done] {result_str}", flush=True)
|
||||
@@ -472,11 +518,32 @@ async def run_agent_session(
|
||||
|
||||
# Check if build is complete
|
||||
if is_build_complete(spec_dir):
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - build is complete",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "complete", response_text
|
||||
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - continuing",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "continue", response_text
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"session",
|
||||
f"Session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during agent session: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
|
||||
@@ -28,6 +28,8 @@ except ImportError:
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
|
||||
|
||||
@@ -40,10 +42,10 @@ MAX_ATTEMPTS_TO_INCLUDE = 3
|
||||
|
||||
def is_extraction_enabled() -> bool:
|
||||
"""Check if insight extraction is enabled."""
|
||||
# Extraction requires Claude SDK and OAuth token
|
||||
# Extraction requires Claude SDK and authentication token
|
||||
if not SDK_AVAILABLE:
|
||||
return False
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
if not get_auth_token():
|
||||
return False
|
||||
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
|
||||
return enabled_str in ("true", "1", "yes")
|
||||
@@ -348,11 +350,13 @@ async def run_insight_extraction(
|
||||
logger.warning("Claude SDK not available, skipping insight extraction")
|
||||
return None
|
||||
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
logger.warning("CLAUDE_CODE_OAUTH_TOKEN not set, skipping insight extraction")
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found, skipping insight extraction")
|
||||
return None
|
||||
|
||||
# Ensure SDK can find the token
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
model = get_extraction_model()
|
||||
prompt = _build_extraction_prompt(inputs)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def handle_build_command(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use
|
||||
model: Model to use (used as default; may be overridden by task_metadata.json)
|
||||
max_iterations: Maximum number of iterations (None for unlimited)
|
||||
verbose: Enable verbose output
|
||||
force_isolated: Force isolated workspace mode
|
||||
@@ -86,14 +86,29 @@ def handle_build_command(
|
||||
debug_section,
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
# Get the resolved model for the planning phase (first phase of build)
|
||||
# This respects task_metadata.json phase configuration from the UI
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
coding_model = get_phase_model(spec_dir, "coding", model)
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
|
||||
print_banner()
|
||||
print(f"\nProject directory: {project_dir}")
|
||||
print(f"Spec: {spec_dir.name}")
|
||||
print(f"Model: {model}")
|
||||
# Show phase-specific models if they differ
|
||||
if planning_model != coding_model or coding_model != qa_model:
|
||||
print(
|
||||
f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, "
|
||||
f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, "
|
||||
f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}"
|
||||
)
|
||||
else:
|
||||
print(f"Model: {planning_model}")
|
||||
|
||||
if max_iterations:
|
||||
print(f"Max iterations: {max_iterations}")
|
||||
|
||||
@@ -14,6 +14,7 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import AUTH_TOKEN_ENV_VARS, get_auth_token, get_auth_token_source
|
||||
from dotenv import load_dotenv
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
@@ -95,14 +96,25 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
"""
|
||||
valid = True
|
||||
|
||||
# Check for Claude Code OAuth token
|
||||
if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
print("Error: CLAUDE_CODE_OAUTH_TOKEN environment variable not set")
|
||||
print("\nGet your OAuth token by running:")
|
||||
# Check for authentication token (supports multiple env vars)
|
||||
if not get_auth_token():
|
||||
print("Error: No authentication token found")
|
||||
print(f"\nSet one of: {', '.join(AUTH_TOKEN_ENV_VARS)}")
|
||||
print("\nFor Claude Code CLI, get your OAuth token by running:")
|
||||
print(" claude setup-token")
|
||||
print("\nThen set it:")
|
||||
print(" export CLAUDE_CODE_OAUTH_TOKEN='your-token-here'")
|
||||
valid = False
|
||||
else:
|
||||
# Show which auth source is being used
|
||||
source = get_auth_token_source()
|
||||
if source and source != "CLAUDE_CODE_OAUTH_TOKEN":
|
||||
print(f"Auth: Using token from {source}")
|
||||
|
||||
# Show custom base URL if set
|
||||
base_url = os.environ.get("ANTHROPIC_BASE_URL")
|
||||
if base_url:
|
||||
print(f"API Endpoint: {base_url}")
|
||||
|
||||
# Check for spec.md in spec directory
|
||||
spec_file = spec_dir / "spec.md"
|
||||
|
||||
@@ -5,6 +5,7 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +30,106 @@ from workspace import (
|
||||
|
||||
from .utils import print_banner
|
||||
|
||||
|
||||
def _detect_default_branch(project_dir: Path) -> str:
|
||||
"""
|
||||
Detect the default branch for the repository.
|
||||
|
||||
This matches the logic in WorktreeManager._detect_base_branch() to ensure
|
||||
we compare against the same branch that worktrees are created from.
|
||||
|
||||
Priority order:
|
||||
1. DEFAULT_BRANCH environment variable
|
||||
2. Auto-detect main/master (if they exist)
|
||||
3. Fall back to "main" as final default
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
The detected default branch name
|
||||
"""
|
||||
import os
|
||||
|
||||
# 1. Check for DEFAULT_BRANCH env var
|
||||
env_branch = os.getenv("DEFAULT_BRANCH")
|
||||
if env_branch:
|
||||
# Verify the branch exists
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
|
||||
# 2. Auto-detect main/master
|
||||
for branch in ["main", "master"]:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
|
||||
# 3. Fall back to "main" as final default
|
||||
return "main"
|
||||
|
||||
|
||||
def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try without the three-dot notation
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -381,6 +482,18 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
# Detect the default branch (main/master) that worktrees are created from
|
||||
# Note: git_conflicts["base_branch"] is the current project branch, not the
|
||||
# worktree base, so we detect the default branch separately
|
||||
default_branch = _detect_default_branch(project_dir)
|
||||
all_changed_files = _get_changed_files_from_git(worktree_path, default_branch)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Git diff against '{default_branch}' shows {len(all_changed_files)} changed files",
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
# Initialize the orchestrator
|
||||
@@ -456,9 +569,15 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Use git diff file count as the authoritative totalFiles count
|
||||
# The semantic tracker may not track all files (e.g., test files, config files)
|
||||
# but we want to show the user all files that will be merged
|
||||
total_files_from_git = len(all_changed_files)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"files": preview.get("files_to_merge", []),
|
||||
# Use git diff files as the authoritative list of files to merge
|
||||
"files": all_changed_files,
|
||||
"conflicts": conflicts,
|
||||
"gitConflicts": {
|
||||
"hasConflicts": git_conflicts["has_conflicts"]
|
||||
@@ -470,7 +589,8 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
},
|
||||
"summary": {
|
||||
"totalFiles": summary.get("total_files", 0),
|
||||
# Use git diff count, not semantic tracker count
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
@@ -485,6 +605,8 @@ def handle_merge_preview_command(project_dir: Path, spec_name: str) -> dict:
|
||||
MODULE,
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Authentication helpers for Auto Claude.
|
||||
|
||||
Provides centralized authentication token resolution with fallback support
|
||||
for multiple environment variables, and SDK environment variable passthrough
|
||||
for custom API endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Priority order for auth token resolution
|
||||
AUTH_TOKEN_ENV_VARS = [
|
||||
"CLAUDE_CODE_OAUTH_TOKEN", # Original (highest priority)
|
||||
"ANTHROPIC_AUTH_TOKEN", # CCR/proxy token
|
||||
"ANTHROPIC_API_KEY", # Direct API key (lowest priority)
|
||||
]
|
||||
|
||||
# Environment variables to pass through to SDK subprocess
|
||||
SDK_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"NO_PROXY",
|
||||
"DISABLE_TELEMETRY",
|
||||
"DISABLE_COST_WARNINGS",
|
||||
"API_TIMEOUT_MS",
|
||||
]
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables.
|
||||
|
||||
Checks multiple env vars in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (original)
|
||||
2. ANTHROPIC_AUTH_TOKEN (ccr/proxy)
|
||||
3. ANTHROPIC_API_KEY (direct API key)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the env var that provided the auth token."""
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
return None
|
||||
|
||||
|
||||
def require_auth_token() -> str:
|
||||
"""
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported env var
|
||||
"""
|
||||
token = get_auth_token()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"No authentication token found.\n"
|
||||
f"Set one of: {', '.join(AUTH_TOKEN_ENV_VARS)}\n"
|
||||
"For Claude Code CLI: run 'claude setup-token'"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
|
||||
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
||||
be passed through to the claude-agent-sdk subprocess.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
env = {}
|
||||
for var in SDK_ENV_VARS:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
return env
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
|
||||
|
||||
If not set but other auth tokens are available, copies the value
|
||||
to CLAUDE_CODE_OAUTH_TOKEN so the underlying SDK can use it.
|
||||
"""
|
||||
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
return
|
||||
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
||||
@@ -18,6 +18,7 @@ from auto_claude_tools import (
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from linear_updater import is_linear_enabled
|
||||
from security import bash_security_hook
|
||||
|
||||
@@ -131,6 +132,7 @@ def create_client(
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
agent_type: str = "coder",
|
||||
max_thinking_tokens: int | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
@@ -141,6 +143,11 @@ def create_client(
|
||||
model: Claude model to use
|
||||
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
|
||||
This determines which custom auto-claude tools are available.
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
@@ -152,12 +159,12 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if not oauth_token:
|
||||
raise ValueError(
|
||||
"CLAUDE_CODE_OAUTH_TOKEN environment variable not set.\n"
|
||||
"Get your token by running: claude setup-token"
|
||||
)
|
||||
oauth_token = require_auth_token()
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
@@ -236,6 +243,10 @@ def create_client(
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"]
|
||||
if linear_enabled:
|
||||
@@ -318,5 +329,7 @@ def create_client(
|
||||
max_turns=1000,
|
||||
cwd=str(project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
|
||||
)
|
||||
)
|
||||
|
||||
@@ -164,6 +164,35 @@ def merge_existing_build(
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name}"))
|
||||
return False
|
||||
|
||||
# Detect current branch - this is where user wants changes merged
|
||||
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
|
||||
# and wants to merge the spec changes into it, then PR to main
|
||||
current_branch_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
current_branch = (
|
||||
current_branch_result.stdout.strip()
|
||||
if current_branch_result.returncode == 0
|
||||
else None
|
||||
)
|
||||
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
|
||||
# Don't merge a branch into itself
|
||||
if current_branch == spec_branch:
|
||||
print()
|
||||
print_status(
|
||||
"You're on the spec branch. Switch to your target branch first.", "warning"
|
||||
)
|
||||
print()
|
||||
print("Example:")
|
||||
print(highlight(" git checkout main # or your feature branch"))
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
|
||||
return False
|
||||
|
||||
if no_commit:
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} STAGING BUILD FOR REVIEW"),
|
||||
@@ -178,7 +207,8 @@ def merge_existing_build(
|
||||
print()
|
||||
print(box(content, width=60, style="heavy"))
|
||||
|
||||
manager = WorktreeManager(project_dir)
|
||||
# Use current branch as merge target (not auto-detected main/master)
|
||||
manager = WorktreeManager(project_dir, base_branch=current_branch)
|
||||
show_build_summary(manager, spec_name)
|
||||
print()
|
||||
|
||||
@@ -197,7 +227,9 @@ def merge_existing_build(
|
||||
|
||||
if had_conflicts:
|
||||
# Git conflicts were resolved (via AI or lock file exclusion) - changes are already staged
|
||||
_print_merge_success(no_commit, stats, spec_name=spec_name, keep_worktree=True)
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
|
||||
# Don't auto-delete worktree - let user test and manually cleanup
|
||||
# User can delete with: python auto-claude/run.py --spec <name> --discard
|
||||
@@ -210,7 +242,9 @@ def merge_existing_build(
|
||||
spec_name, delete_after=False, no_commit=no_commit
|
||||
)
|
||||
if success_result:
|
||||
_print_merge_success(no_commit, stats, spec_name=spec_name, keep_worktree=True)
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
return True
|
||||
elif smart_result.get("git_conflicts"):
|
||||
# Had git conflicts that AI couldn't fully resolve
|
||||
|
||||
@@ -74,7 +74,7 @@ def print_merge_success(
|
||||
no_commit: bool,
|
||||
stats: dict | None = None,
|
||||
spec_name: str | None = None,
|
||||
keep_worktree: bool = False
|
||||
keep_worktree: bool = False,
|
||||
) -> None:
|
||||
"""Print a success message after merge."""
|
||||
from ui import Icons, box, icon
|
||||
@@ -131,7 +131,9 @@ def print_merge_success(
|
||||
]
|
||||
)
|
||||
if spec_name:
|
||||
lines.append(f" python auto-claude/run.py --spec {spec_name} --discard")
|
||||
lines.append(
|
||||
f" python auto-claude/run.py --spec {spec_name} --discard"
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
[
|
||||
|
||||
@@ -235,6 +235,23 @@ class WorktreeManager:
|
||||
**stats,
|
||||
)
|
||||
|
||||
def _check_branch_namespace_conflict(self) -> str | None:
|
||||
"""
|
||||
Check if a branch named 'auto-claude' exists, which would block creating
|
||||
branches in the 'auto-claude/*' namespace.
|
||||
|
||||
Git stores branch refs as files under .git/refs/heads/, so a branch named
|
||||
'auto-claude' creates a file that prevents creating the 'auto-claude/'
|
||||
directory needed for 'auto-claude/{spec-name}' branches.
|
||||
|
||||
Returns:
|
||||
The conflicting branch name if found, None otherwise.
|
||||
"""
|
||||
result = self._run_git(["rev-parse", "--verify", "auto-claude"])
|
||||
if result.returncode == 0:
|
||||
return "auto-claude"
|
||||
return None
|
||||
|
||||
def _get_worktree_stats(self, spec_name: str) -> dict:
|
||||
"""Get diff statistics for a worktree."""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
@@ -283,10 +300,26 @@ class WorktreeManager:
|
||||
|
||||
Returns:
|
||||
WorktreeInfo for the created worktree
|
||||
|
||||
Raises:
|
||||
WorktreeError: If a branch namespace conflict exists or worktree creation fails
|
||||
"""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
branch_name = self.get_branch_name(spec_name)
|
||||
|
||||
# Check for branch namespace conflict (e.g., 'auto-claude' blocking 'auto-claude/*')
|
||||
conflicting_branch = self._check_branch_namespace_conflict()
|
||||
if conflicting_branch:
|
||||
raise WorktreeError(
|
||||
f"Branch '{conflicting_branch}' exists and blocks creating '{branch_name}'.\n"
|
||||
f"\n"
|
||||
f"Git branch names work like file paths - a branch named 'auto-claude' prevents\n"
|
||||
f"creating branches under 'auto-claude/' (like 'auto-claude/{spec_name}').\n"
|
||||
f"\n"
|
||||
f"Fix: Rename the conflicting branch:\n"
|
||||
f" git branch -m {conflicting_branch} {conflicting_branch}-backup"
|
||||
)
|
||||
|
||||
# Remove existing if present (from crashed previous run)
|
||||
if worktree_path.exists():
|
||||
self._run_git(["worktree", "remove", "--force", str(worktree_path)])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user