Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdca9af3b8 | |||
| 06fc5dab10 | |||
| a960f00307 | |||
| a05216590b | |||
| 57fcc2403b | |||
| c93fe96ee2 | |||
| 6ee5a731f4 | |||
| f6601efc8a | |||
| a03fa8bc80 | |||
| 50f739dc16 | |||
| 14238788c9 | |||
| 39a08f6117 | |||
| 87e12cf627 | |||
| 4c8dfcafa7 | |||
| 17b092ba39 | |||
| 4b09b0c47e | |||
| b64faed197 | |||
| 252d4ccfd8 | |||
| 721b12753c | |||
| 757e5e04d2 | |||
| 1d1e15446d | |||
| 69d5c7323f | |||
| 9a03814e14 | |||
| c52caa6b17 | |||
| 12c8519246 | |||
| 8bcd00e4a6 | |||
| 7649a607e6 | |||
| f89e4e6c56 | |||
| b9797cbe21 | |||
| cc38a0619c | |||
| aee0ba4cc5 | |||
| 9981ee4469 | |||
| 297d380f4c | |||
| 05062562f0 | |||
| 438f6e2237 | |||
| 458d4bb97a | |||
| 10949905f7 | |||
| 9ab5a4f2cc | |||
| f0a6a0a0af | |||
| cdda3ff277 | |||
| 08aa2ff02b | |||
| 37ace0a39a | |||
| 7f0eeba366 | |||
| f82bd5b871 | |||
| f117bccbbc | |||
| 8b59375404 | |||
| 91a1e3df6c | |||
| 7f12ef0355 | |||
| 30921550df | |||
| 2b96160ab0 | |||
| 7171589002 | |||
| 2a96f855ae | |||
| 3ac3f067cc | |||
| 535d58c80f | |||
| 2ef90b980f | |||
| e6654b96c5 | |||
| 5db28fd8e8 | |||
| c1207ef7fc | |||
| 70072e4281 | |||
| ba776a3fb8 | |||
| e2b24e2e25 | |||
| 7589046bbe | |||
| e248256649 | |||
| 76c1bd7578 | |||
| bcbced24e5 | |||
| a75c0a9965 | |||
| c505d6e32c | |||
| 7d053313b5 | |||
| e9535c8dc4 | |||
| 1642719445 | |||
| 3efab867c5 | |||
| 2ca89ce7c9 | |||
| 52e12d8d2a | |||
| c5b72451af | |||
| ffd8b153a5 | |||
| ee168d317f | |||
| a335925eae | |||
| cf1ba6b57b | |||
| ce7c95cae7 | |||
| 4b6a59826e | |||
| e6058168f0 | |||
| c7dde1f979 | |||
| 15a7585f6e | |||
| c486e5ba84 | |||
| d94833a678 | |||
| 376e950bd4 | |||
| 0959e790df | |||
| e134c4cba9 | |||
| 81e1536801 | |||
| 1a7cf409eb | |||
| 5f26d3964d | |||
| 4a4ad6b1df | |||
| 5702692940 | |||
| 6a4c1b452b | |||
| b75a09c88c | |||
| 0601520e9b | |||
| 412ed0be3c | |||
| 586aa9f8c3 | |||
| bc6470f5c3 | |||
| a107ed03a3 | |||
| 679b8cd948 | |||
| cece172df6 | |||
| 1a38a06e6e | |||
| fe691066dd | |||
| 0f47961a8c | |||
| 9299ee107a | |||
| 6680ed49f6 | |||
| a3eee9285e | |||
| 01a4eb6bbf | |||
| b8a419af5a | |||
| 2b61ebbfad | |||
| 4750869526 | |||
| c9745b6669 | |||
| 08b65f315a | |||
| e1aee6a44f | |||
| b3636a5bce | |||
| 908eebfb16 | |||
| 0e6b652dd7 | |||
| 873cafa46f |
@@ -0,0 +1,71 @@
|
||||
name: Validate Version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
name: Validate package.json version matches tag
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: tag_version
|
||||
run: |
|
||||
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package_version
|
||||
run: |
|
||||
# Read version from package.json
|
||||
PACKAGE_VERSION=$(node -p "require('./auto-claude-ui/package.json').version")
|
||||
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package.json version: $PACKAGE_VERSION"
|
||||
|
||||
- name: Compare versions
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
|
||||
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Validation"
|
||||
echo "=========================================="
|
||||
echo "Git tag version: v$TAG_VERSION"
|
||||
echo "package.json version: $PACKAGE_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Version mismatch detected!"
|
||||
echo ""
|
||||
echo "The version in package.json ($PACKAGE_VERSION) does not match"
|
||||
echo "the git tag version ($TAG_VERSION)."
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
|
||||
echo " 2. Update package.json version to $TAG_VERSION"
|
||||
echo " 3. Commit the change"
|
||||
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
|
||||
echo ""
|
||||
echo "Or use the automated script:"
|
||||
echo " node scripts/bump-version.js $TAG_VERSION"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ SUCCESS: Versions match!"
|
||||
echo ""
|
||||
|
||||
- name: Version validation result
|
||||
if: success()
|
||||
run: |
|
||||
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
|
||||
+229
@@ -1,3 +1,232 @@
|
||||
## 2.6.0 - Improved User Experience and Agent Configuration
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Add customizable phase configuration in app settings, allowing users to tailor the AI build pipeline to their workflow
|
||||
|
||||
- Implement parallel AI merge functionality for faster integration of completed builds
|
||||
|
||||
- Add Google AI as LLM and embedding provider for Graphiti memory system
|
||||
|
||||
- Implement device code authentication flow with timeout handling, browser launch fallback, and comprehensive testing
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Move Agent Profiles from dashboard to Settings for better organization and discoverability
|
||||
|
||||
- Default agent profile to 'Auto (Optimized)' for streamlined out-of-the-box experience
|
||||
|
||||
- Enhance WorkspaceStatus component UI with improved visual design
|
||||
|
||||
- Refactor task management from sidebar to modal interface for cleaner navigation
|
||||
|
||||
- Add comprehensive theme system with multiple color schemes (Forest, Neo, Retro, Dusk, Ocean, Lime) and light/dark mode support
|
||||
|
||||
- Extract human-readable feature titles from spec.md for better task identification
|
||||
|
||||
- Improve task description display for specs with compact markdown formatting
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fix asyncio coroutine creation in worker threads to properly support async operations
|
||||
|
||||
- Improve UX for phase configuration in task creation workflow
|
||||
|
||||
- Address CodeRabbit PR #69 feedback and additional review comments
|
||||
|
||||
- Fix auto-close behavior for task modal when marking tasks as done
|
||||
|
||||
- Resolve Python lint errors and import sorting issues (ruff I001 compliance)
|
||||
|
||||
- Ensure planner agent properly writes implementation_plan.json
|
||||
|
||||
- Add platform detection for terminal profile commands on Windows
|
||||
|
||||
- Set default selected agent profile to 'auto' across all users
|
||||
|
||||
- Fix display of correct merge target branch in worktree UI
|
||||
|
||||
- Add validation for invalid colorTheme fallback to prevent UI errors
|
||||
|
||||
- Remove outdated Sun/Moon toggle button from sidebar
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat: add customizable phase configuration in app settings by @AndyMik90 in aee0ba4
|
||||
|
||||
- feat: implement parallel AI merge functionality by @AndyMik90 in 458d4bb
|
||||
|
||||
- feat(graphiti): add Google AI as LLM and embedding provider by @adryserage in fe69106
|
||||
|
||||
- fix: create coroutine inside worker thread for asyncio.run by @AndyMik90 in f89e4e6
|
||||
|
||||
- fix: improve UX for phase configuration in task creation by @AndyMik90 in b9797cb
|
||||
|
||||
- fix: address CodeRabbit PR #69 feedback by @AndyMik90 in cc38a06
|
||||
|
||||
- fix: sort imports in workspace.py to pass ruff I001 check by @AndyMik90 in 9981ee4
|
||||
|
||||
- fix(ui): auto-close task modal when marking task as done by @AndyMik90 in 297d380
|
||||
|
||||
- fix: resolve Python lint errors in workspace.py by @AndyMik90 in 0506256
|
||||
|
||||
- refactor: move Agent Profiles from dashboard to Settings by @AndyMik90 in 1094990
|
||||
|
||||
- fix(planning): ensure planner agent writes implementation_plan.json by @AndyMik90 in 9ab5a4f
|
||||
|
||||
- fix(windows): add platform detection for terminal profile commands by @AndyMik90 in f0a6a0a
|
||||
|
||||
- fix: default agent profile to 'Auto (Optimized)' for all users by @AndyMik90 in 08aa2ff
|
||||
|
||||
- fix: update default selected agent profile to 'auto' by @AndyMik90 in 37ace0a
|
||||
|
||||
- style: enhance WorkspaceStatus component UI by @AndyMik90 in 3092155
|
||||
|
||||
- fix: display correct merge target branch in worktree UI by @AndyMik90 in 2b96160
|
||||
|
||||
- Improvement/refactor task sidebar to task modal by @AndyMik90 in 2a96f85
|
||||
|
||||
- fix: extract human-readable title from spec.md when feature field is spec ID by @AndyMik90 in 8b59375
|
||||
|
||||
- fix: task descriptions not showing for specs with compact markdown by @AndyMik90 in 7f12ef0
|
||||
|
||||
- Add comprehensive theme system with Forest, Neo, Retro, Dusk, Ocean, and Lime color schemes by @AndyMik90 in ba776a3, e2b24e2, 7589046, e248256, 76c1bd7, bcbced2
|
||||
|
||||
- Add ColorTheme type and configuration to app settings by @AndyMik90 in 2ca89ce, c505d6e, a75c0a9
|
||||
|
||||
- Implement device code authentication flow with timeout handling and fallback URL display by @AndyMik90 in 5f26d39, 81e1536, 1a7cf40, 4a4ad6b, 6a4c1b4, b75a09c, e134c4c
|
||||
|
||||
- fix(graphiti): address CodeRabbit review comments by @adryserage in 679b8cd
|
||||
|
||||
- fix(lint): sort imports in Google provider files by @adryserage in 1a38a06
|
||||
|
||||
## 2.6.0 - Multi-Provider Graphiti Support & Platform Fixes
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Google AI Provider for Graphiti**: Full Google AI (Gemini) support for both LLM and embeddings in the Memory Layer
|
||||
- Add GoogleLLMClient with gemini-2.0-flash default model
|
||||
- Add GoogleEmbedder with text-embedding-004 default model
|
||||
- UI integration for Google API key configuration with link to Google AI Studio
|
||||
- **Ollama LLM Provider in UI**: Add Ollama as an LLM provider option in Graphiti onboarding wizard
|
||||
- Ollama runs locally and doesn't require an API key
|
||||
- Configure Base URL instead of API key for local inference
|
||||
- **LLM Provider Selection UI**: Add provider selection dropdown to Graphiti setup wizard for flexible backend configuration
|
||||
- **Per-Project GitHub Configuration**: UI clarity improvements for per-project GitHub org/repo settings
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Enhanced Graphiti provider factory to support Google AI alongside existing providers
|
||||
- Updated env-handlers to properly populate graphitiProviderConfig from .env files
|
||||
- Improved type definitions with proper Graphiti provider config properties in AppSettings
|
||||
- Better API key loading when switching between providers in settings
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **node-pty Migration**: Replaced node-pty with @lydell/node-pty for prebuilt Windows binaries
|
||||
- Updated all imports to use @lydell/node-pty directly
|
||||
- Fixed "Cannot find module 'node-pty'" startup error
|
||||
- **GitHub Organization Support**: Fixed repository support for GitHub organization accounts
|
||||
- Add defensive array validation for GitHub issues API response
|
||||
- **Asyncio Deprecation**: Fixed asyncio deprecation warning by using get_running_loop() instead of get_event_loop()
|
||||
- Applied ruff formatting and fixed import sorting (I001) in Google provider files
|
||||
|
||||
### 🔧 Other Changes
|
||||
|
||||
- Added google-generativeai dependency to requirements.txt
|
||||
- Updated provider validation to include Google/Groq/HuggingFace type assertions
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(graphiti): address CodeRabbit review comments by @adryserage in 679b8cd
|
||||
- fix(lint): sort imports in Google provider files by @adryserage in 1a38a06
|
||||
- feat(graphiti): add Google AI as LLM and embedding provider by @adryserage in fe69106
|
||||
- fix: GitHub organization repository support by @mojaray2k in 873cafa
|
||||
- feat(ui): add LLM provider selection to Graphiti onboarding by @adryserage in 4750869
|
||||
- fix(types): add missing AppSettings properties for Graphiti providers by @adryserage in 6680ed4
|
||||
- feat(ui): add Ollama as LLM provider option for Graphiti by @adryserage in a3eee92
|
||||
- fix(ui): address PR review feedback for Graphiti provider selection by @adryserage in b8a419a
|
||||
- fix(deps): update imports to use @lydell/node-pty directly by @adryserage in 2b61ebb
|
||||
- fix(deps): replace node-pty with @lydell/node-pty for prebuilt binaries by @adryserage in e1aee6a
|
||||
- fix: add UI clarity for per-project GitHub configuration by @mojaray2k in c9745b6
|
||||
- fix: add defensive array validation for GitHub issues API response by @mojaray2k in b3636a5
|
||||
|
||||
---
|
||||
|
||||
## 2.5.5 - Enhanced Agent Reliability & Build Workflow
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Required GitHub setup flow after Auto Claude initialization to ensure proper configuration
|
||||
- Atomic log saving mechanism to prevent log file corruption during concurrent operations
|
||||
- Per-session model and thinking level selection in insights management
|
||||
- Multi-auth token support and ANTHROPIC_BASE_URL passthrough for flexible authentication
|
||||
- Comprehensive DEBUG logging at Claude SDK invocation points for improved troubleshooting
|
||||
- Auto-download of prebuilt node-pty binaries for Windows environments
|
||||
- Enhanced merge workflow with current branch detection for accurate change previews
|
||||
- Phase configuration module and enhanced agent profiles for improved flexibility
|
||||
- Stage-only merge handling with comprehensive verification checks
|
||||
- Authentication failure detection system with patterns and validation checks across agent pipeline
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Changed default agent profile from 'balanced' to 'auto' for more adaptive behavior
|
||||
- Better GitHub issue tracking and improved user experience in issue management
|
||||
- Improved merge preview accuracy using git diff counts for file statistics
|
||||
- Preserved roadmap generation state when switching between projects
|
||||
- Enhanced agent profiles with phase configuration support
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Resolved CI test failures and improved merge preview reliability
|
||||
- Fixed CI failures related to linting, formatting, and tests
|
||||
- Prevented dialog skip during project initialization flow
|
||||
- Updated model IDs for Sonnet and Haiku to match current Claude versions
|
||||
- Fixed branch namespace conflict detection to prevent worktree creation failures
|
||||
- Removed duplicate LINEAR_API_KEY checks and consolidated imports
|
||||
- Python 3.10+ version requirement enforced with proper version checking
|
||||
- Prevented command injection vulnerabilities in GitHub API calls
|
||||
|
||||
### 🔧 Other Changes
|
||||
|
||||
- Code cleanup and test fixture updates
|
||||
- Removed redundant auto-claude/specs directory structure
|
||||
- Untracked .auto-claude directory to respect gitignore rules
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: resolve CI test failures and improve merge preview by @AndyMik90 in de2eccd
|
||||
- chore: code cleanup and test fixture updates by @AndyMik90 in 948db57
|
||||
- refactor: change default agent profile from 'balanced' to 'auto' by @AndyMik90 in f98a13e
|
||||
- security: prevent command injection in GitHub API calls by @AndyMik90 in 24ff491
|
||||
- fix: resolve CI failures (lint, format, test) by @AndyMik90 in a8f2d0b
|
||||
- fix: use git diff count for totalFiles in merge preview by @AndyMik90 in 46d2536
|
||||
- feat: enhance stage-only merge handling with verification checks by @AndyMik90 in 7153558
|
||||
- feat: introduce phase configuration module and enhance agent profiles by @AndyMik90 in 2672528
|
||||
- fix: preserve roadmap generation state when switching projects by @AndyMik90 in 569e921
|
||||
- feat: add required GitHub setup flow after Auto Claude initialization by @AndyMik90 in 03ccce5
|
||||
- chore: remove redundant auto-claude/specs directory by @AndyMik90 in 64d5170
|
||||
- chore: untrack .auto-claude directory (should be gitignored) by @AndyMik90 in 0710c13
|
||||
- fix: prevent dialog skip during project initialization by @AndyMik90 in 56cedec
|
||||
- feat: enhance merge workflow by detecting current branch by @AndyMik90 in c0c8067
|
||||
- fix: update model IDs for Sonnet and Haiku by @AndyMik90 in 059315d
|
||||
- feat: add comprehensive DEBUG logging and fix lint errors by @AndyMik90 in 99cf21e
|
||||
- feat: implement atomic log saving to prevent corruption by @AndyMik90 in da5e26b
|
||||
- feat: add better github issue tracking and UX by @AndyMik90 in c957eaa
|
||||
- feat: add comprehensive DEBUG logging to Claude SDK invocation points by @AndyMik90 in 73d01c0
|
||||
- feat: auto-download prebuilt node-pty binaries for Windows by @AndyMik90 in 41a507f
|
||||
- feat(insights): add per-session model and thinking level selection by @AndyMik90 in e02aa59
|
||||
- fix: require Python 3.10+ and add version check by @AndyMik90 in 9a5ca8c
|
||||
- fix: detect branch namespace conflict blocking worktree creation by @AndyMik90 in 63a1d3c
|
||||
- fix: remove duplicate LINEAR_API_KEY check and consolidate imports by @Jacob in 7d351e3
|
||||
- feat: add multi-auth token support and ANTHROPIC_BASE_URL passthrough by @Jacob in 9dea155
|
||||
|
||||
## 2.5.0 - Roadmap Intelligence & Workflow Refinements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -81,6 +81,21 @@ auto-claude/.venv/bin/pytest tests/ -m "not slow"
|
||||
python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --checkpoint all
|
||||
```
|
||||
|
||||
### Releases
|
||||
```bash
|
||||
# Automated version bump and release (recommended)
|
||||
node scripts/bump-version.js patch # 2.5.5 -> 2.5.6
|
||||
node scripts/bump-version.js minor # 2.5.5 -> 2.6.0
|
||||
node scripts/bump-version.js major # 2.5.5 -> 3.0.0
|
||||
node scripts/bump-version.js 2.6.0 # Set specific version
|
||||
|
||||
# Then push to trigger GitHub release workflows
|
||||
git push origin main
|
||||
git push origin v2.6.0
|
||||
```
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for detailed release process documentation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Pipeline
|
||||
@@ -103,7 +118,7 @@ python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature --c
|
||||
- **worktree.py** - Git worktree isolation for safe feature development
|
||||
- **memory.py** - File-based session memory (primary, always-available storage)
|
||||
- **graphiti_memory.py** - Optional graph-based cross-session memory with semantic search
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama)
|
||||
- **graphiti_providers.py** - Multi-provider factory for Graphiti (OpenAI, Anthropic, Azure, Ollama, Google AI)
|
||||
- **graphiti_config.py** - Configuration and validation for Graphiti integration
|
||||
- **linear_updater.py** - Optional Linear integration for progress tracking
|
||||
|
||||
@@ -177,8 +192,8 @@ Dual-layer memory architecture:
|
||||
- Graph database with semantic search (FalkorDB)
|
||||
- Cross-session context retrieval
|
||||
- Multi-provider support (V2):
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI (Gemini)
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
|
||||
|
||||
Enable with: `GRAPHITI_ENABLED=true` + provider credentials. See `.env.example`.
|
||||
|
||||
|
||||
@@ -248,12 +248,13 @@ The Memory Layer is a **hybrid RAG system** combining graph nodes with semantic
|
||||
**Architecture:**
|
||||
- **Backend**: FalkorDB (graph database) via Docker
|
||||
- **Library**: Graphiti for knowledge graph operations
|
||||
- **Providers**: OpenAI, Anthropic, Azure OpenAI, or Ollama (local/offline)
|
||||
- **Providers**: OpenAI, Anthropic, Azure OpenAI, Google AI, or Ollama (local/offline)
|
||||
|
||||
| Setup | LLM | Embeddings | Notes |
|
||||
|-------|-----|------------|-------|
|
||||
| **OpenAI** | OpenAI | OpenAI | Simplest - single API key |
|
||||
| **Anthropic + Voyage** | Anthropic | Voyage AI | High quality |
|
||||
| **Google AI** | Gemini | Google | Single API key, fast inference |
|
||||
| **Ollama** | Ollama | Ollama | Fully offline |
|
||||
| **Azure** | Azure OpenAI | Azure OpenAI | Enterprise |
|
||||
|
||||
@@ -309,11 +310,12 @@ The `.auto-claude/` directory is gitignored and project-specific - you'll have o
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `AUTO_BUILD_MODEL` | No | Model override (default: claude-opus-4-5-20251101) |
|
||||
| `GRAPHITI_ENABLED` | Recommended | Set to `true` to enable Memory Layer |
|
||||
| `GRAPHITI_LLM_PROVIDER` | For Memory | LLM provider: openai, anthropic, azure_openai, ollama |
|
||||
| `GRAPHITI_EMBEDDER_PROVIDER` | For Memory | Embedder: openai, voyage, azure_openai, ollama |
|
||||
| `GRAPHITI_LLM_PROVIDER` | For Memory | LLM provider: openai, anthropic, azure_openai, ollama, google |
|
||||
| `GRAPHITI_EMBEDDER_PROVIDER` | For Memory | Embedder: openai, voyage, azure_openai, ollama, google |
|
||||
| `OPENAI_API_KEY` | For OpenAI | Required for OpenAI provider |
|
||||
| `ANTHROPIC_API_KEY` | For Anthropic | Required for Anthropic LLM |
|
||||
| `VOYAGE_API_KEY` | For Voyage | Required for Voyage embeddings |
|
||||
| `GOOGLE_API_KEY` | For Google | Required for Google AI (Gemini) provider |
|
||||
|
||||
See `auto-claude/.env.example` for complete configuration options.
|
||||
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# Release Process
|
||||
|
||||
This document describes how to create a new release of Auto Claude.
|
||||
|
||||
## Automated Release Process (Recommended)
|
||||
|
||||
We provide an automated script that handles version bumping, git commits, and tagging to ensure version consistency.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Clean git working directory (no uncommitted changes)
|
||||
- You're on the branch you want to release from (usually `main`)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Run the version bump script:**
|
||||
|
||||
```bash
|
||||
# Bump patch version (2.5.5 -> 2.5.6)
|
||||
node scripts/bump-version.js patch
|
||||
|
||||
# Bump minor version (2.5.5 -> 2.6.0)
|
||||
node scripts/bump-version.js minor
|
||||
|
||||
# Bump major version (2.5.5 -> 3.0.0)
|
||||
node scripts/bump-version.js major
|
||||
|
||||
# Set specific version
|
||||
node scripts/bump-version.js 2.6.0
|
||||
```
|
||||
|
||||
This script will:
|
||||
- ✅ Update `auto-claude-ui/package.json` with the new version
|
||||
- ✅ Create a git commit with the version change
|
||||
- ✅ Create a git tag (e.g., `v2.5.6`)
|
||||
- ⚠️ **NOT** push to remote (you control when to push)
|
||||
|
||||
2. **Review the changes:**
|
||||
|
||||
```bash
|
||||
git log -1 # View the commit
|
||||
git show v2.5.6 # View the tag
|
||||
```
|
||||
|
||||
3. **Push to GitHub:**
|
||||
|
||||
```bash
|
||||
# Push the commit
|
||||
git push origin main
|
||||
|
||||
# Push the tag
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
4. **Create GitHub Release:**
|
||||
|
||||
- Go to [GitHub Releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
- Click "Draft a new release"
|
||||
- Select the tag you just pushed (e.g., `v2.5.6`)
|
||||
- Add release notes (describe what changed)
|
||||
- Click "Publish release"
|
||||
|
||||
5. **Automated builds will trigger:**
|
||||
|
||||
- ✅ Version validation workflow will verify version consistency
|
||||
- ✅ Tests will run (`test-on-tag.yml`)
|
||||
- ✅ Native module prebuilds will be created (`build-prebuilds.yml`)
|
||||
- ✅ Discord notification will be sent (`discord-release.yml`)
|
||||
|
||||
## Manual Release Process (Not Recommended)
|
||||
|
||||
If you need to create a release manually, follow these steps **carefully** to avoid version mismatches:
|
||||
|
||||
1. **Update `auto-claude-ui/package.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.5.6"
|
||||
}
|
||||
```
|
||||
|
||||
2. **Commit the change:**
|
||||
|
||||
```bash
|
||||
git add auto-claude-ui/package.json
|
||||
git commit -m "chore: bump version to 2.5.6"
|
||||
```
|
||||
|
||||
3. **Create and push tag:**
|
||||
|
||||
```bash
|
||||
git tag -a v2.5.6 -m "Release v2.5.6"
|
||||
git push origin main
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
4. **Create GitHub Release** (same as step 4 above)
|
||||
|
||||
## Version Validation
|
||||
|
||||
A GitHub Action automatically validates that the version in `package.json` matches the git tag.
|
||||
|
||||
If there's a mismatch, the workflow will **fail** with a clear error message:
|
||||
|
||||
```
|
||||
❌ ERROR: Version mismatch detected!
|
||||
|
||||
The version in package.json (2.5.0) does not match
|
||||
the git tag version (2.5.5).
|
||||
|
||||
To fix this:
|
||||
1. Delete this tag: git tag -d v2.5.5
|
||||
2. Update package.json version to 2.5.5
|
||||
3. Commit the change
|
||||
4. Recreate the tag: git tag -a v2.5.5 -m 'Release v2.5.5'
|
||||
```
|
||||
|
||||
This validation ensures we never ship a release where the updater shows the wrong version.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Version Mismatch Error
|
||||
|
||||
If you see a version mismatch error in GitHub Actions:
|
||||
|
||||
1. **Delete the incorrect tag:**
|
||||
```bash
|
||||
git tag -d v2.5.6 # Delete locally
|
||||
git push origin :refs/tags/v2.5.6 # Delete remotely
|
||||
```
|
||||
|
||||
2. **Use the automated script:**
|
||||
```bash
|
||||
node scripts/bump-version.js 2.5.6
|
||||
git push origin main
|
||||
git push origin v2.5.6
|
||||
```
|
||||
|
||||
### Git Working Directory Not Clean
|
||||
|
||||
If the version bump script fails with "Git working directory is not clean":
|
||||
|
||||
```bash
|
||||
# Commit or stash your changes first
|
||||
git status
|
||||
git add .
|
||||
git commit -m "your changes"
|
||||
|
||||
# Then run the version bump script
|
||||
node scripts/bump-version.js patch
|
||||
```
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Use this checklist when creating a new release:
|
||||
|
||||
- [ ] All tests passing on main branch
|
||||
- [ ] CHANGELOG updated (if applicable)
|
||||
- [ ] Run `node scripts/bump-version.js <type>`
|
||||
- [ ] Review commit and tag
|
||||
- [ ] Push commit and tag to GitHub
|
||||
- [ ] Create GitHub Release with release notes
|
||||
- [ ] Verify version validation passed
|
||||
- [ ] Verify builds completed successfully
|
||||
- [ ] Test the updater shows correct version
|
||||
|
||||
## What Gets Released
|
||||
|
||||
When you create a release, the following are built and published:
|
||||
|
||||
1. **Native module prebuilds** - Windows node-pty binaries
|
||||
2. **Electron app packages** - Desktop installers (triggered manually or via electron-builder)
|
||||
3. **Discord notification** - Sent to the Auto Claude community
|
||||
|
||||
## Version Numbering
|
||||
|
||||
We follow [Semantic Versioning (SemVer)](https://semver.org/):
|
||||
|
||||
- **MAJOR** version (X.0.0) - Breaking changes
|
||||
- **MINOR** version (0.X.0) - New features (backward compatible)
|
||||
- **PATCH** version (0.0.X) - Bug fixes (backward compatible)
|
||||
|
||||
Examples:
|
||||
- `2.5.5 -> 2.5.6` - Bug fix
|
||||
- `2.5.6 -> 2.6.0` - New feature
|
||||
- `2.6.0 -> 3.0.0` - Breaking change
|
||||
@@ -5,23 +5,20 @@
|
||||
# DEBUG SETTINGS
|
||||
# ============================================
|
||||
|
||||
# Enable general debug logging for ideation and roadmap features
|
||||
# Enable debug logging across the entire application
|
||||
# When enabled, you'll see detailed console logs for:
|
||||
# - Ideation generation and stop functionality
|
||||
# - Roadmap generation and stop functionality
|
||||
# - Ideation and roadmap generation
|
||||
# - IPC communication between processes
|
||||
# - Store state updates
|
||||
# - Changelog generation and project initialization
|
||||
# - GitHub OAuth flow
|
||||
# Usage: Set to 'true' before starting the app
|
||||
# DEBUG=true
|
||||
|
||||
# Enable debug logging for the auto-updater
|
||||
# Enable debug logging for the auto-updater only
|
||||
# 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
|
||||
# ============================================
|
||||
@@ -31,7 +28,6 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineConfig({
|
||||
index: resolve(__dirname, 'src/main/index.ts')
|
||||
},
|
||||
// Only node-pty needs to be external (native module rebuilt by electron-builder)
|
||||
external: ['node-pty']
|
||||
external: ['@lydell/node-pty']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+29
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -150,7 +150,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -536,7 +535,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -560,7 +558,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -600,7 +597,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -995,6 +991,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1016,6 +1013,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -3930,7 +3928,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -4127,7 +4126,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4138,7 +4136,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4230,7 +4227,6 @@
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
@@ -4630,8 +4626,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
@@ -4653,7 +4648,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4714,7 +4708,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4887,6 +4880,7 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -5271,7 +5265,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5951,7 +5944,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6295,7 +6289,6 @@
|
||||
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.0.12",
|
||||
"builder-util": "26.0.11",
|
||||
@@ -6353,7 +6346,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -6429,7 +6423,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
@@ -6558,6 +6551,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -6578,6 +6572,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -6593,6 +6588,7 @@
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@@ -6603,6 +6599,7 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
@@ -6972,7 +6969,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8992,7 +8988,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -9936,6 +9931,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -11775,7 +11771,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11873,7 +11868,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11910,6 +11904,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -11927,6 +11922,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -11947,6 +11943,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -11962,6 +11959,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -11974,7 +11972,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "2.0.1",
|
||||
@@ -12078,7 +12077,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12088,7 +12086,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13405,8 +13402,7 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -13463,6 +13459,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -13489,6 +13486,7 @@
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -13510,6 +13508,7 @@
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -13523,6 +13522,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -13537,6 +13537,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -13853,7 +13854,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14194,7 +14194,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -15228,7 +15227,6 @@
|
||||
"integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.5",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Auto Claude Team",
|
||||
@@ -8,8 +8,10 @@
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:mcp": "electron-vite dev -- --remote-debugging-port=9222",
|
||||
"build": "electron-vite build",
|
||||
"start": "electron .",
|
||||
"start:mcp": "electron . --remote-debugging-port=9222",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-vite build && electron-builder",
|
||||
"package:mac": "electron-vite build && electron-builder --mac",
|
||||
@@ -30,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lydell/node-pty": "^1.1.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
@@ -59,7 +62,6 @@
|
||||
"ioredis": "^5.8.2",
|
||||
"lucide-react": "^0.560.0",
|
||||
"motion": "^12.23.26",
|
||||
"node-pty": "^1.1.0-beta42",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -104,12 +106,13 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"electron-builder-squirrel-windows": "^26.0.12",
|
||||
"dmg-builder": "^26.0.12"
|
||||
"dmg-builder": "^26.0.12",
|
||||
"node-pty": "npm:@lydell/node-pty@^1.1.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"node-pty"
|
||||
"electron-winstaller",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
@@ -132,8 +135,8 @@
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "node_modules/node-pty",
|
||||
"to": "node_modules/node-pty"
|
||||
"from": "node_modules/@lydell/node-pty",
|
||||
"to": "node_modules/@lydell/node-pty"
|
||||
},
|
||||
{
|
||||
"from": "resources/icon.ico",
|
||||
@@ -180,5 +183,6 @@
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
|
||||
}
|
||||
|
||||
Generated
+645
-335
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,14 @@ vi.mock('@electron-toolkit/utils', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock version-manager to return a predictable version
|
||||
vi.mock('../updater/version-manager', () => ({
|
||||
getEffectiveVersion: vi.fn(() => '0.1.0'),
|
||||
getBundledVersion: vi.fn(() => '0.1.0'),
|
||||
parseVersionFromTag: vi.fn((tag: string) => tag.replace('v', '')),
|
||||
compareVersions: vi.fn(() => 0)
|
||||
}));
|
||||
|
||||
// Mock modules before importing
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
|
||||
@@ -9,8 +9,9 @@ import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
IdeationConfig
|
||||
RoadmapConfig
|
||||
} from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Main AgentManager - orchestrates agent process lifecycle
|
||||
@@ -243,9 +244,11 @@ export class AgentManager extends EventEmitter {
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false,
|
||||
enableCompetitorAnalysis: boolean = false
|
||||
enableCompetitorAnalysis: boolean = false,
|
||||
refreshCompetitorAnalysis: boolean = false,
|
||||
config?: RoadmapConfig
|
||||
): void {
|
||||
this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh, enableCompetitorAnalysis);
|
||||
this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh, enableCompetitorAnalysis, refreshCompetitorAnalysis, config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProcessType, ExecutionProgressData } from './types';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { projectStore } from '../project-store';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { findPythonCommand, parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Process spawning and lifecycle management
|
||||
@@ -17,7 +18,8 @@ export class AgentProcessManager {
|
||||
private state: AgentState;
|
||||
private events: AgentEvents;
|
||||
private emitter: EventEmitter;
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
|
||||
@@ -161,7 +163,9 @@ export class AgentProcessManager {
|
||||
// Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default)
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -237,6 +241,10 @@ export class AgentProcessManager {
|
||||
const log = data.toString('utf8');
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
@@ -246,6 +254,10 @@ export class AgentProcessManager {
|
||||
// so we treat it as log, not error
|
||||
this.emitter.emit('log', taskId, log);
|
||||
processLog(log);
|
||||
// Print to console when DEBUG is enabled (visible in pnpm dev terminal)
|
||||
if (['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '')) {
|
||||
console.log(`[Agent:${taskId}] ${log.trim()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
|
||||
@@ -5,9 +5,12 @@ import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { IdeationConfig } from './types';
|
||||
import { RoadmapConfig } from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Queue management for ideation and roadmap generation
|
||||
@@ -32,18 +35,26 @@ export class AgentQueueManager {
|
||||
|
||||
/**
|
||||
* Start roadmap generation process
|
||||
*
|
||||
* @param refreshCompetitorAnalysis - Force refresh competitor analysis even if it exists.
|
||||
* This allows refreshing competitor data independently of the general roadmap refresh.
|
||||
* Use when user explicitly wants new competitor research.
|
||||
*/
|
||||
startRoadmapGeneration(
|
||||
projectId: string,
|
||||
projectPath: string,
|
||||
refresh: boolean = false,
|
||||
enableCompetitorAnalysis: boolean = false
|
||||
enableCompetitorAnalysis: boolean = false,
|
||||
refreshCompetitorAnalysis: boolean = false,
|
||||
config?: RoadmapConfig
|
||||
): void {
|
||||
debugLog('[Agent Queue] Starting roadmap generation:', {
|
||||
projectId,
|
||||
projectPath,
|
||||
refresh,
|
||||
enableCompetitorAnalysis
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
|
||||
@@ -73,6 +84,20 @@ export class AgentQueueManager {
|
||||
args.push('--competitor-analysis');
|
||||
}
|
||||
|
||||
// Add refresh competitor analysis flag if user wants fresh competitor data
|
||||
if (refreshCompetitorAnalysis) {
|
||||
args.push('--refresh-competitor-analysis');
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
if (config?.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
}
|
||||
if (config?.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning roadmap process with args:', args);
|
||||
|
||||
// Use projectId as taskId for roadmap operations
|
||||
@@ -140,6 +165,15 @@ export class AgentQueueManager {
|
||||
args.push('--append');
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
if (config.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
}
|
||||
if (config.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
}
|
||||
|
||||
debugLog('[Agent Queue] Spawning ideation process with args:', args);
|
||||
|
||||
// Use projectId as taskId for ideation operations
|
||||
@@ -206,7 +240,9 @@ export class AgentQueueManager {
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: finalEnv
|
||||
});
|
||||
@@ -315,6 +351,15 @@ export class AgentQueueManager {
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Ideation process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Check if this process was intentionally stopped by the user
|
||||
const wasIntentionallyStopped = this.state.wasSpawnKilled(spawnId);
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Ideation process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
@@ -441,7 +486,9 @@ export class AgentQueueManager {
|
||||
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
|
||||
});
|
||||
|
||||
const childProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd,
|
||||
env: finalEnv
|
||||
});
|
||||
@@ -512,6 +559,15 @@ export class AgentQueueManager {
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
debugLog('[Agent Queue] Roadmap process exited:', { projectId, code, spawnId });
|
||||
|
||||
// Check if this process was intentionally stopped by the user
|
||||
const wasIntentionallyStopped = this.state.wasSpawnKilled(spawnId);
|
||||
if (wasIntentionallyStopped) {
|
||||
debugLog('[Agent Queue] Roadmap process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the stored project path before deleting from map
|
||||
const processInfo = this.state.getProcess(projectId);
|
||||
const storedProjectPath = processInfo?.projectPath;
|
||||
|
||||
@@ -20,9 +20,11 @@ export type {
|
||||
ExecutionProgressData,
|
||||
ProcessType,
|
||||
AgentManagerEvents,
|
||||
IdeationConfig,
|
||||
TaskExecutionOptions,
|
||||
SpecCreationMetadata,
|
||||
IdeationProgressData,
|
||||
RoadmapProgressData
|
||||
} from './types';
|
||||
|
||||
// Re-export IdeationConfig from shared types for consistency
|
||||
export type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Agent-specific types for process and state management
|
||||
@@ -32,12 +33,11 @@ export interface AgentManagerEvents {
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
}
|
||||
|
||||
export interface IdeationConfig {
|
||||
enabledTypes: string[];
|
||||
includeRoadmapContext: boolean;
|
||||
includeKanbanContext: boolean;
|
||||
maxIdeasPerType: number;
|
||||
append?: boolean;
|
||||
// IdeationConfig now imported from shared types to maintain consistency
|
||||
|
||||
export interface RoadmapConfig {
|
||||
model?: string; // Model shorthand (opus, sonnet, haiku)
|
||||
thinkingLevel?: string; // Thinking level (none, low, medium, high, ultrathink)
|
||||
}
|
||||
|
||||
export interface TaskExecutionOptions {
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { AppUpdateInfo } from '../shared/types';
|
||||
|
||||
// Debug mode - set via environment variable
|
||||
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.DEBUG === 'true';
|
||||
// Debug mode - DEBUG_UPDATER=true or development mode
|
||||
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
// Configure electron-updater
|
||||
autoUpdater.autoDownload = true; // Automatically download updates when available
|
||||
|
||||
@@ -27,7 +27,7 @@ export type {
|
||||
} from './updater/types';
|
||||
|
||||
// Export version management
|
||||
export { getBundledVersion } from './updater/version-manager';
|
||||
export { getBundledVersion, getEffectiveVersion } from './updater/version-manager';
|
||||
|
||||
// Export path resolution
|
||||
export {
|
||||
|
||||
@@ -27,13 +27,15 @@ import {
|
||||
getCommits,
|
||||
getBranchDiffCommits
|
||||
} from './git-integration';
|
||||
import { findPythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Main changelog service - orchestrates all changelog operations
|
||||
* Delegates to specialized modules for specific concerns
|
||||
*/
|
||||
export class ChangelogService extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private claudePath: string = 'claude';
|
||||
private autoBuildSourcePath: string = '';
|
||||
private cachedEnv: Record<string, string> | null = null;
|
||||
@@ -89,7 +91,7 @@ export class ChangelogService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
* Checks DEBUG from auto-claude/.env and AUTO_CLAUDE_DEBUG from process.env
|
||||
* Checks DEBUG from auto-claude/.env and DEBUG from process.env
|
||||
*/
|
||||
private isDebugEnabled(): boolean {
|
||||
// Cache the result after first check
|
||||
@@ -101,8 +103,8 @@ export class ChangelogService extends EventEmitter {
|
||||
if (
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.DEBUG === '1' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === 'true' ||
|
||||
process.env.AUTO_CLAUDE_DEBUG === '1'
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.DEBUG === '1'
|
||||
) {
|
||||
this.debugEnabled = true;
|
||||
return true;
|
||||
@@ -115,7 +117,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when DEBUG=true in auto-claude/.env or AUTO_CLAUDE_DEBUG is set
|
||||
* Debug logging - only logs when DEBUG=true in auto-claude/.env or DEBUG is set
|
||||
*/
|
||||
private debug(...args: unknown[]): void {
|
||||
if (this.isDebugEnabled()) {
|
||||
|
||||
@@ -49,7 +49,11 @@ const FORMAT_TEMPLATES = {
|
||||
|
||||
## What's Changed
|
||||
|
||||
- type: description by @contributor in commit-hash`
|
||||
- type: description by @contributor in commit-hash
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@contributor1, @contributor2`
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -274,7 +278,15 @@ PART 2 - "What's Changed" (raw commit list):
|
||||
- Example: "- feat: add dark mode support by @contributor in def5678"
|
||||
- Include the commit type prefix (feat:, fix:, docs:, etc.)
|
||||
- Show the author name with @ prefix
|
||||
- Show the short commit hash at the end`;
|
||||
- Show the short commit hash at the end
|
||||
|
||||
PART 3 - "Thanks to all contributors" (deduplicated list):
|
||||
- Add this section after "What's Changed"
|
||||
- Extract all unique contributor names from the commits
|
||||
- List them in a comma-separated format with @ prefix
|
||||
- Example: "## Thanks to all contributors\\n\\n@contributor1, @contributor2, @contributor3"
|
||||
- Only include unique names (no duplicates)
|
||||
- This acknowledges everyone who contributed to this release`;
|
||||
}
|
||||
|
||||
return `${audienceInstruction}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './
|
||||
import { extractChangelog } from './parser';
|
||||
import { getCommits, getBranchDiffCommits } from './git-integration';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Core changelog generation logic
|
||||
@@ -139,7 +140,9 @@ export class ChangelogGenerator extends EventEmitter {
|
||||
// Build environment with explicit critical variables
|
||||
const spawnEnv = this.buildSpawnEnvironment();
|
||||
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: this.autoBuildSourcePath,
|
||||
env: spawnEnv
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { GitCommit } from '../../shared/types';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
|
||||
interface VersionSuggestion {
|
||||
version: string;
|
||||
@@ -52,7 +53,9 @@ export class VersionSuggester {
|
||||
const spawnEnv = this.buildSpawnEnvironment();
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: this.autoBuildSourcePath,
|
||||
env: spawnEnv
|
||||
});
|
||||
|
||||
@@ -99,7 +99,9 @@ export class UsageMonitor extends EventEmitter {
|
||||
}
|
||||
|
||||
// Fetch current usage (hybrid approach)
|
||||
const usage = await this.fetchUsage(activeProfile.id, activeProfile.oauthToken);
|
||||
// Get decrypted token from ProfileManager (activeProfile.oauthToken is encrypted)
|
||||
const decryptedToken = profileManager.getProfileToken(activeProfile.id);
|
||||
const usage = await this.fetchUsage(activeProfile.id, decryptedToken ?? undefined);
|
||||
if (!usage) {
|
||||
console.warn('[UsageMonitor] Failed to fetch usage');
|
||||
return;
|
||||
|
||||
@@ -141,16 +141,9 @@ app.whenReady().then(() => {
|
||||
|
||||
// Log debug mode status
|
||||
const isDebugMode = process.env.DEBUG === 'true';
|
||||
const isAutoClaudeDebug = process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
if (isDebugMode || isAutoClaudeDebug) {
|
||||
if (isDebugMode) {
|
||||
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] DEBUG MODE ENABLED (DEBUG=true)');
|
||||
console.warn('[main] ========================================');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@ import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { findPythonCommand } from '../python-detector';
|
||||
|
||||
/**
|
||||
* Configuration manager for insights service
|
||||
* Handles path detection and environment variable loading
|
||||
*/
|
||||
export class InsightsConfig {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { EventEmitter } from 'events';
|
||||
import type {
|
||||
InsightsChatMessage,
|
||||
@@ -86,12 +87,27 @@ export class InsightsExecutor extends EventEmitter {
|
||||
// Get process environment
|
||||
const processEnv = this.config.getProcessEnv();
|
||||
|
||||
// Write conversation history to temp file to avoid Windows command-line length limit
|
||||
const historyFile = path.join(
|
||||
os.tmpdir(),
|
||||
`insights-history-${projectId}-${Date.now()}.json`
|
||||
);
|
||||
|
||||
let historyFileCreated = false;
|
||||
try {
|
||||
writeFileSync(historyFile, JSON.stringify(conversationHistory), 'utf-8');
|
||||
historyFileCreated = true;
|
||||
} catch (err) {
|
||||
console.error('[Insights] Failed to write history file:', err);
|
||||
throw new Error('Failed to write conversation history to temp file');
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
runnerPath,
|
||||
'--project-dir', projectPath,
|
||||
'--message', message,
|
||||
'--history', JSON.stringify(conversationHistory)
|
||||
'--history-file', historyFile
|
||||
];
|
||||
|
||||
// Add model config if provided
|
||||
@@ -151,6 +167,15 @@ export class InsightsExecutor extends EventEmitter {
|
||||
proc.on('close', (code) => {
|
||||
this.activeSessions.delete(projectId);
|
||||
|
||||
// Cleanup temp file
|
||||
if (historyFileCreated && existsSync(historyFile)) {
|
||||
try {
|
||||
unlinkSync(historyFile);
|
||||
} catch (cleanupErr) {
|
||||
console.error('[Insights] Failed to cleanup history file:', cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limit if process failed
|
||||
if (code !== 0) {
|
||||
this.handleRateLimit(projectId, allInsightsOutput);
|
||||
@@ -184,6 +209,16 @@ export class InsightsExecutor extends EventEmitter {
|
||||
|
||||
proc.on('error', (err) => {
|
||||
this.activeSessions.delete(projectId);
|
||||
|
||||
// Cleanup temp file
|
||||
if (historyFileCreated && existsSync(historyFile)) {
|
||||
try {
|
||||
unlinkSync(historyFile);
|
||||
} catch (cleanupErr) {
|
||||
console.error('[Insights] Failed to cleanup history file:', cleanupErr);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('error', projectId, err.message);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Integration module for external roadmap/feedback services
|
||||
*
|
||||
* Currently provides architecture for future integrations with:
|
||||
* - Canny.io (feedback management)
|
||||
* - GitHub Issues
|
||||
*
|
||||
* To add a new integration:
|
||||
* 1. Implement the IntegrationAdapter interface
|
||||
* 2. Add status mapping constants
|
||||
* 3. Register the adapter in this module
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
|
||||
// Future: Export concrete adapter implementations
|
||||
// export { CannyAdapter } from './canny-adapter';
|
||||
// export { GitHubIssuesAdapter } from './github-issues-adapter';
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Integration provider types for external roadmap services (Canny, GitHub Issues, etc.)
|
||||
*
|
||||
* This architecture allows bidirectional sync with external feedback/roadmap systems:
|
||||
* - Import: Fetch feature requests from external services
|
||||
* - Export: Push status updates back when features progress
|
||||
*/
|
||||
|
||||
import type { RoadmapFeatureStatus } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Represents an item from an external feedback/roadmap system
|
||||
*/
|
||||
export interface FeedbackItem {
|
||||
externalId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
votes: number;
|
||||
status: string; // Provider-specific status
|
||||
url: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection status for a provider
|
||||
*/
|
||||
export interface ProviderConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
lastSync?: Date;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a provider
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
boardId?: string;
|
||||
autoSync?: boolean;
|
||||
syncIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract interface for integration adapters
|
||||
*
|
||||
* Implement this interface to add support for new external services.
|
||||
* Each adapter handles mapping between internal and external status systems.
|
||||
*/
|
||||
export interface IntegrationAdapter {
|
||||
/** Unique identifier for this provider */
|
||||
readonly providerId: string;
|
||||
|
||||
/** Display name for the provider */
|
||||
readonly providerName: string;
|
||||
|
||||
/**
|
||||
* Test the connection to the external service
|
||||
*/
|
||||
testConnection(): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
/**
|
||||
* Fetch all items from the external service
|
||||
*/
|
||||
fetchItems(): Promise<FeedbackItem[]>;
|
||||
|
||||
/**
|
||||
* Update the status of an item in the external service
|
||||
*/
|
||||
updateStatus(externalId: string, status: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Map internal roadmap status to provider-specific status
|
||||
*/
|
||||
mapStatusToProvider(internalStatus: RoadmapFeatureStatus): string;
|
||||
|
||||
/**
|
||||
* Map provider-specific status to internal roadmap status
|
||||
*/
|
||||
mapStatusFromProvider(externalStatus: string): RoadmapFeatureStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canny-specific status mapping
|
||||
* Reference: https://developers.canny.io/api-reference
|
||||
*/
|
||||
export const CANNY_STATUS_MAP = {
|
||||
toProvider: {
|
||||
under_review: 'under review',
|
||||
planned: 'planned',
|
||||
in_progress: 'in progress',
|
||||
done: 'complete'
|
||||
} as Record<RoadmapFeatureStatus, string>,
|
||||
|
||||
fromProvider: {
|
||||
'open': 'under_review',
|
||||
'under review': 'under_review',
|
||||
'planned': 'planned',
|
||||
'in progress': 'in_progress',
|
||||
'complete': 'done',
|
||||
'closed': 'done'
|
||||
} as Record<string, RoadmapFeatureStatus>
|
||||
};
|
||||
|
||||
/**
|
||||
* GitHub Issues status mapping
|
||||
*/
|
||||
export const GITHUB_ISSUES_STATUS_MAP = {
|
||||
toProvider: {
|
||||
under_review: 'open',
|
||||
planned: 'open',
|
||||
in_progress: 'open',
|
||||
done: 'closed'
|
||||
} as Record<RoadmapFeatureStatus, string>,
|
||||
|
||||
fromProvider: {
|
||||
'open': 'under_review',
|
||||
'closed': 'done'
|
||||
} as Record<string, RoadmapFeatureStatus>
|
||||
};
|
||||
@@ -5,7 +5,8 @@ import type { IPCResult } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,10 +22,16 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
|
||||
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; releaseUrl?: string; error?: string }>> => {
|
||||
console.log('[autobuild-source] Check for updates called');
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK called');
|
||||
try {
|
||||
const result = await checkSourceUpdates();
|
||||
console.log('[autobuild-source] Check result:', JSON.stringify(result, null, 2));
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK result:', result);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('[autobuild-source] Check error:', error);
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates'
|
||||
@@ -36,25 +43,33 @@ export function registerAutobuildSourceHandlers(
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
|
||||
() => {
|
||||
debugLog('[IPC] Autobuild source download requested');
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
if (!mainWindow) {
|
||||
debugLog('[IPC] No main window available, aborting update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start download in background
|
||||
downloadAndApplyUpdate((progress) => {
|
||||
debugLog('[IPC] Update progress:', progress.stage, progress.message);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
progress
|
||||
);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
debugLog('[IPC] Update completed successfully, version:', result.version);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'complete',
|
||||
message: `Updated to version ${result.version}`
|
||||
message: `Updated to version ${result.version}`,
|
||||
newVersion: result.version // Include new version for UI refresh
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
} else {
|
||||
debugLog('[IPC] Update failed:', result.error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -64,6 +79,7 @@ export function registerAutobuildSourceHandlers(
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
debugLog('[IPC] Update error:', error instanceof Error ? error.message : error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
@@ -88,7 +104,9 @@ export function registerAutobuildSourceHandlers(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
|
||||
async (): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
const version = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
debugLog('[IPC] Returning effective version:', version);
|
||||
return { success: true, data: version };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -62,9 +62,48 @@ export function registerEnvHandlers(
|
||||
if (config.githubAutoSync !== undefined) {
|
||||
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
|
||||
}
|
||||
// Git/Worktree Settings
|
||||
if (config.defaultBranch !== undefined) {
|
||||
existingVars['DEFAULT_BRANCH'] = config.defaultBranch;
|
||||
}
|
||||
if (config.graphitiEnabled !== undefined) {
|
||||
existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false';
|
||||
}
|
||||
// Graphiti Provider Configuration
|
||||
if (config.graphitiProviderConfig) {
|
||||
const pc = config.graphitiProviderConfig;
|
||||
if (pc.llmProvider) existingVars['GRAPHITI_LLM_PROVIDER'] = pc.llmProvider;
|
||||
if (pc.embeddingProvider) existingVars['GRAPHITI_EMBEDDER_PROVIDER'] = pc.embeddingProvider;
|
||||
// OpenAI
|
||||
if (pc.openaiApiKey) existingVars['OPENAI_API_KEY'] = pc.openaiApiKey;
|
||||
if (pc.openaiModel) existingVars['OPENAI_MODEL'] = pc.openaiModel;
|
||||
if (pc.openaiEmbeddingModel) existingVars['OPENAI_EMBEDDING_MODEL'] = pc.openaiEmbeddingModel;
|
||||
// Anthropic
|
||||
if (pc.anthropicApiKey) existingVars['ANTHROPIC_API_KEY'] = pc.anthropicApiKey;
|
||||
if (pc.anthropicModel) existingVars['GRAPHITI_ANTHROPIC_MODEL'] = pc.anthropicModel;
|
||||
// Azure OpenAI
|
||||
if (pc.azureOpenaiApiKey) existingVars['AZURE_OPENAI_API_KEY'] = pc.azureOpenaiApiKey;
|
||||
if (pc.azureOpenaiBaseUrl) existingVars['AZURE_OPENAI_BASE_URL'] = pc.azureOpenaiBaseUrl;
|
||||
if (pc.azureOpenaiLlmDeployment) existingVars['AZURE_OPENAI_LLM_DEPLOYMENT'] = pc.azureOpenaiLlmDeployment;
|
||||
if (pc.azureOpenaiEmbeddingDeployment) existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'] = pc.azureOpenaiEmbeddingDeployment;
|
||||
// Voyage
|
||||
if (pc.voyageApiKey) existingVars['VOYAGE_API_KEY'] = pc.voyageApiKey;
|
||||
if (pc.voyageEmbeddingModel) existingVars['VOYAGE_EMBEDDING_MODEL'] = pc.voyageEmbeddingModel;
|
||||
// Google
|
||||
if (pc.googleApiKey) existingVars['GOOGLE_API_KEY'] = pc.googleApiKey;
|
||||
if (pc.googleLlmModel) existingVars['GOOGLE_LLM_MODEL'] = pc.googleLlmModel;
|
||||
if (pc.googleEmbeddingModel) existingVars['GOOGLE_EMBEDDING_MODEL'] = pc.googleEmbeddingModel;
|
||||
// Ollama
|
||||
if (pc.ollamaBaseUrl) existingVars['OLLAMA_BASE_URL'] = pc.ollamaBaseUrl;
|
||||
if (pc.ollamaLlmModel) existingVars['OLLAMA_LLM_MODEL'] = pc.ollamaLlmModel;
|
||||
if (pc.ollamaEmbeddingModel) existingVars['OLLAMA_EMBEDDING_MODEL'] = pc.ollamaEmbeddingModel;
|
||||
if (pc.ollamaEmbeddingDim) existingVars['OLLAMA_EMBEDDING_DIM'] = String(pc.ollamaEmbeddingDim);
|
||||
// FalkorDB
|
||||
if (pc.falkorDbHost) existingVars['GRAPHITI_FALKORDB_HOST'] = pc.falkorDbHost;
|
||||
if (pc.falkorDbPort) existingVars['GRAPHITI_FALKORDB_PORT'] = String(pc.falkorDbPort);
|
||||
if (pc.falkorDbPassword) existingVars['GRAPHITI_FALKORDB_PASSWORD'] = pc.falkorDbPassword;
|
||||
}
|
||||
// Legacy fields (still supported)
|
||||
if (config.openaiApiKey !== undefined) {
|
||||
existingVars['OPENAI_API_KEY'] = config.openaiApiKey;
|
||||
}
|
||||
@@ -109,6 +148,13 @@ ${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}`
|
||||
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
|
||||
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Default base branch for worktree creation
|
||||
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch
|
||||
${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANCH']}` : '# DEFAULT_BRANCH=main'}
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
@@ -116,9 +162,45 @@ ${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVar
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI MEMORY INTEGRATION (OPTIONAL)
|
||||
# Multi-provider support: OpenAI, Anthropic, Google AI, Azure OpenAI, Ollama, Voyage
|
||||
# =============================================================================
|
||||
${existingVars['GRAPHITI_ENABLED'] ? `GRAPHITI_ENABLED=${existingVars['GRAPHITI_ENABLED']}` : '# GRAPHITI_ENABLED=false'}
|
||||
|
||||
# Provider Selection
|
||||
${existingVars['GRAPHITI_LLM_PROVIDER'] ? `GRAPHITI_LLM_PROVIDER=${existingVars['GRAPHITI_LLM_PROVIDER']}` : '# GRAPHITI_LLM_PROVIDER=openai'}
|
||||
${existingVars['GRAPHITI_EMBEDDER_PROVIDER'] ? `GRAPHITI_EMBEDDER_PROVIDER=${existingVars['GRAPHITI_EMBEDDER_PROVIDER']}` : '# GRAPHITI_EMBEDDER_PROVIDER=openai'}
|
||||
|
||||
# OpenAI Settings
|
||||
${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KEY']}` : '# OPENAI_API_KEY='}
|
||||
${existingVars['OPENAI_MODEL'] ? `OPENAI_MODEL=${existingVars['OPENAI_MODEL']}` : '# OPENAI_MODEL=gpt-4o-mini'}
|
||||
${existingVars['OPENAI_EMBEDDING_MODEL'] ? `OPENAI_EMBEDDING_MODEL=${existingVars['OPENAI_EMBEDDING_MODEL']}` : '# OPENAI_EMBEDDING_MODEL=text-embedding-3-small'}
|
||||
|
||||
# Anthropic Settings (LLM only - use with Voyage or OpenAI for embeddings)
|
||||
${existingVars['ANTHROPIC_API_KEY'] ? `ANTHROPIC_API_KEY=${existingVars['ANTHROPIC_API_KEY']}` : '# ANTHROPIC_API_KEY='}
|
||||
${existingVars['GRAPHITI_ANTHROPIC_MODEL'] ? `GRAPHITI_ANTHROPIC_MODEL=${existingVars['GRAPHITI_ANTHROPIC_MODEL']}` : '# GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest'}
|
||||
|
||||
# Azure OpenAI Settings
|
||||
${existingVars['AZURE_OPENAI_API_KEY'] ? `AZURE_OPENAI_API_KEY=${existingVars['AZURE_OPENAI_API_KEY']}` : '# AZURE_OPENAI_API_KEY='}
|
||||
${existingVars['AZURE_OPENAI_BASE_URL'] ? `AZURE_OPENAI_BASE_URL=${existingVars['AZURE_OPENAI_BASE_URL']}` : '# AZURE_OPENAI_BASE_URL='}
|
||||
${existingVars['AZURE_OPENAI_LLM_DEPLOYMENT'] ? `AZURE_OPENAI_LLM_DEPLOYMENT=${existingVars['AZURE_OPENAI_LLM_DEPLOYMENT']}` : '# AZURE_OPENAI_LLM_DEPLOYMENT='}
|
||||
${existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'] ? `AZURE_OPENAI_EMBEDDING_DEPLOYMENT=${existingVars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT']}` : '# AZURE_OPENAI_EMBEDDING_DEPLOYMENT='}
|
||||
|
||||
# Voyage AI Settings (Embeddings only - great with Anthropic)
|
||||
${existingVars['VOYAGE_API_KEY'] ? `VOYAGE_API_KEY=${existingVars['VOYAGE_API_KEY']}` : '# VOYAGE_API_KEY='}
|
||||
${existingVars['VOYAGE_EMBEDDING_MODEL'] ? `VOYAGE_EMBEDDING_MODEL=${existingVars['VOYAGE_EMBEDDING_MODEL']}` : '# VOYAGE_EMBEDDING_MODEL=voyage-3'}
|
||||
|
||||
# Google AI Settings (LLM and Embeddings - Gemini)
|
||||
${existingVars['GOOGLE_API_KEY'] ? `GOOGLE_API_KEY=${existingVars['GOOGLE_API_KEY']}` : '# GOOGLE_API_KEY='}
|
||||
${existingVars['GOOGLE_LLM_MODEL'] ? `GOOGLE_LLM_MODEL=${existingVars['GOOGLE_LLM_MODEL']}` : '# GOOGLE_LLM_MODEL=gemini-2.0-flash'}
|
||||
${existingVars['GOOGLE_EMBEDDING_MODEL'] ? `GOOGLE_EMBEDDING_MODEL=${existingVars['GOOGLE_EMBEDDING_MODEL']}` : '# GOOGLE_EMBEDDING_MODEL=text-embedding-004'}
|
||||
|
||||
# Ollama Settings (Local - free)
|
||||
${existingVars['OLLAMA_BASE_URL'] ? `OLLAMA_BASE_URL=${existingVars['OLLAMA_BASE_URL']}` : '# OLLAMA_BASE_URL=http://localhost:11434'}
|
||||
${existingVars['OLLAMA_LLM_MODEL'] ? `OLLAMA_LLM_MODEL=${existingVars['OLLAMA_LLM_MODEL']}` : '# OLLAMA_LLM_MODEL='}
|
||||
${existingVars['OLLAMA_EMBEDDING_MODEL'] ? `OLLAMA_EMBEDDING_MODEL=${existingVars['OLLAMA_EMBEDDING_MODEL']}` : '# OLLAMA_EMBEDDING_MODEL='}
|
||||
${existingVars['OLLAMA_EMBEDDING_DIM'] ? `OLLAMA_EMBEDDING_DIM=${existingVars['OLLAMA_EMBEDDING_DIM']}` : '# OLLAMA_EMBEDDING_DIM=768'}
|
||||
|
||||
# FalkorDB Connection
|
||||
${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'}
|
||||
${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='}
|
||||
@@ -216,6 +298,11 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
config.githubAutoSync = true;
|
||||
}
|
||||
|
||||
// Git/Worktree config
|
||||
if (vars['DEFAULT_BRANCH']) {
|
||||
config.defaultBranch = vars['DEFAULT_BRANCH'];
|
||||
}
|
||||
|
||||
if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') {
|
||||
config.graphitiEnabled = true;
|
||||
}
|
||||
@@ -246,6 +333,45 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
config.enableFancyUi = false;
|
||||
}
|
||||
|
||||
// Populate graphitiProviderConfig from .env file
|
||||
const llmProvider = vars['GRAPHITI_LLM_PROVIDER'];
|
||||
const embeddingProvider = vars['GRAPHITI_EMBEDDER_PROVIDER'];
|
||||
if (llmProvider || embeddingProvider || vars['ANTHROPIC_API_KEY'] || vars['AZURE_OPENAI_API_KEY'] ||
|
||||
vars['VOYAGE_API_KEY'] || vars['GOOGLE_API_KEY'] || vars['OLLAMA_BASE_URL']) {
|
||||
config.graphitiProviderConfig = {
|
||||
llmProvider: (llmProvider as 'openai' | 'anthropic' | 'azure_openai' | 'ollama' | 'google' | 'groq') || 'openai',
|
||||
embeddingProvider: (embeddingProvider as 'openai' | 'voyage' | 'azure_openai' | 'ollama' | 'google' | 'huggingface') || 'openai',
|
||||
// OpenAI
|
||||
openaiApiKey: vars['OPENAI_API_KEY'],
|
||||
openaiModel: vars['OPENAI_MODEL'],
|
||||
openaiEmbeddingModel: vars['OPENAI_EMBEDDING_MODEL'],
|
||||
// Anthropic
|
||||
anthropicApiKey: vars['ANTHROPIC_API_KEY'],
|
||||
anthropicModel: vars['GRAPHITI_ANTHROPIC_MODEL'],
|
||||
// Azure OpenAI
|
||||
azureOpenaiApiKey: vars['AZURE_OPENAI_API_KEY'],
|
||||
azureOpenaiBaseUrl: vars['AZURE_OPENAI_BASE_URL'],
|
||||
azureOpenaiLlmDeployment: vars['AZURE_OPENAI_LLM_DEPLOYMENT'],
|
||||
azureOpenaiEmbeddingDeployment: vars['AZURE_OPENAI_EMBEDDING_DEPLOYMENT'],
|
||||
// Voyage
|
||||
voyageApiKey: vars['VOYAGE_API_KEY'],
|
||||
voyageEmbeddingModel: vars['VOYAGE_EMBEDDING_MODEL'],
|
||||
// Google
|
||||
googleApiKey: vars['GOOGLE_API_KEY'],
|
||||
googleLlmModel: vars['GOOGLE_LLM_MODEL'],
|
||||
googleEmbeddingModel: vars['GOOGLE_EMBEDDING_MODEL'],
|
||||
// Ollama
|
||||
ollamaBaseUrl: vars['OLLAMA_BASE_URL'],
|
||||
ollamaLlmModel: vars['OLLAMA_LLM_MODEL'],
|
||||
ollamaEmbeddingModel: vars['OLLAMA_EMBEDDING_MODEL'],
|
||||
ollamaEmbeddingDim: vars['OLLAMA_EMBEDDING_DIM'] ? parseInt(vars['OLLAMA_EMBEDDING_DIM'], 10) : undefined,
|
||||
// FalkorDB
|
||||
falkorDbHost: vars['GRAPHITI_FALKORDB_HOST'],
|
||||
falkorDbPort: vars['GRAPHITI_FALKORDB_PORT'] ? parseInt(vars['GRAPHITI_FALKORDB_PORT'], 10) : undefined,
|
||||
falkorDbPassword: vars['GRAPHITI_FALKORDB_PASSWORD'],
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: config };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { IPCResult, FileNode } from '../../shared/types';
|
||||
const IGNORED_DIRS = new Set([
|
||||
'node_modules', '.git', '__pycache__', 'dist', 'build',
|
||||
'.next', '.nuxt', 'coverage', '.cache', '.venv', 'venv',
|
||||
'.idea', '.vscode', 'out', '.turbo', '.auto-claude',
|
||||
'.worktrees', 'vendor', 'target', '.gradle', '.maven'
|
||||
'out', '.turbo', '.worktrees',
|
||||
'vendor', 'target', '.gradle', '.maven'
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -29,8 +29,11 @@ export function registerFileHandlers(): void {
|
||||
// Filter and map entries
|
||||
const nodes: FileNode[] = [];
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files (except .env which is often useful)
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
||||
// Skip hidden files (not directories) except useful ones like .env, .gitignore
|
||||
if (!entry.isDirectory() && entry.name.startsWith('.') &&
|
||||
!['.env', '.gitignore', '.env.example', '.env.local'].includes(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
// Skip ignored directories
|
||||
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* Unit tests for GitHub OAuth handlers
|
||||
* Tests device code parsing, shell.openExternal handling, and error recovery
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock child_process before importing
|
||||
const mockSpawn = vi.fn();
|
||||
const mockExecSync = vi.fn();
|
||||
const mockExecFileSync = vi.fn();
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
execSync: (...args: unknown[]) => mockExecSync(...args),
|
||||
execFileSync: (...args: unknown[]) => mockExecFileSync(...args)
|
||||
}));
|
||||
|
||||
// Mock shell.openExternal
|
||||
const mockOpenExternal = vi.fn();
|
||||
|
||||
vi.mock('electron', () => {
|
||||
const mockIpcMain = new (class extends EventEmitter {
|
||||
private handlers: Map<string, Function> = new Map();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
removeHandler(channel: string): void {
|
||||
this.handlers.delete(channel);
|
||||
}
|
||||
|
||||
async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = this.handlers.get(channel);
|
||||
if (handler) {
|
||||
return handler(event, ...args);
|
||||
}
|
||||
throw new Error(`No handler for channel: ${channel}`);
|
||||
}
|
||||
|
||||
getHandler(channel: string): Function | undefined {
|
||||
return this.handlers.get(channel);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
ipcMain: mockIpcMain,
|
||||
shell: {
|
||||
openExternal: (...args: unknown[]) => mockOpenExternal(...args)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @electron-toolkit/utils
|
||||
vi.mock('@electron-toolkit/utils', () => ({
|
||||
is: {
|
||||
dev: true,
|
||||
windows: process.platform === 'win32',
|
||||
macos: process.platform === 'darwin',
|
||||
linux: process.platform === 'linux'
|
||||
}
|
||||
}));
|
||||
|
||||
// Create mock process for spawn
|
||||
function createMockProcess(): EventEmitter & {
|
||||
stdout: EventEmitter | null;
|
||||
stderr: EventEmitter | null;
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
|
||||
} {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter | null;
|
||||
stderr: EventEmitter | null;
|
||||
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> } | null;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('GitHub OAuth Handlers', () => {
|
||||
let ipcMain: EventEmitter & {
|
||||
handlers: Map<string, Function>;
|
||||
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
getHandler: (channel: string) => Function | undefined;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Get mocked ipcMain
|
||||
const electron = await import('electron');
|
||||
ipcMain = electron.ipcMain as unknown as typeof ipcMain;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Device Code Parsing', () => {
|
||||
it('should parse device code from standard gh CLI output format', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
// Start the handler
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Simulate gh CLI output with device code
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: ABCD-1234\n');
|
||||
mockProcess.stderr?.emit('data', '- Press Enter to open github.com in your browser...\n');
|
||||
|
||||
// Complete the process
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
expect(result).toHaveProperty('data');
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('ABCD-1234');
|
||||
});
|
||||
|
||||
it('should parse device code from alternate output format (lowercase "code")', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Alternate format: "code: XXXX-XXXX" without "one-time"
|
||||
mockProcess.stderr?.emit('data', 'Enter the code: EFGH-5678\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('EFGH-5678');
|
||||
});
|
||||
|
||||
it('should parse device code from stdout (not just stderr)', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Device code in stdout instead of stderr
|
||||
mockProcess.stdout?.emit('data', '! First copy your one-time code: IJKL-9012\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode: string } }).data;
|
||||
expect(data.deviceCode).toBe('IJKL-9012');
|
||||
});
|
||||
|
||||
it('should handle output without device code gracefully', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Output without device code
|
||||
mockProcess.stderr?.emit('data', 'Some other message\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { deviceCode?: string } }).data;
|
||||
expect(data.deviceCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract URL from output containing https://github.com/login/device', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: MNOP-3456\n');
|
||||
mockProcess.stderr?.emit('data', 'Then visit https://github.com/login/device to authenticate\n');
|
||||
mockProcess.emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authUrl: string } }).data;
|
||||
expect(data.authUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell.openExternal Handling', () => {
|
||||
it('should call shell.openExternal with extracted URL when device code found', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: QRST-7890\n');
|
||||
|
||||
// Wait for next tick to allow async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
await resultPromise;
|
||||
|
||||
expect(mockOpenExternal).toHaveBeenCalledWith('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should set browserOpened to true when shell.openExternal succeeds', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: UVWX-1234\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { browserOpened: boolean } }).data;
|
||||
expect(data.browserOpened).toBe(true);
|
||||
});
|
||||
|
||||
it('should set browserOpened to false when shell.openExternal fails', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: YZAB-5678\n');
|
||||
|
||||
// Wait for async browser opening to fail
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { browserOpened: boolean } }).data;
|
||||
expect(data.browserOpened).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide fallbackUrl when browser fails to open', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockRejectedValue(new Error('Failed to open browser'));
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: CDEF-9012\n');
|
||||
|
||||
// Wait for async browser opening to fail
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { fallbackUrl?: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should not provide fallbackUrl when browser opens successfully', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: GHIJ-3456\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { fallbackUrl?: string } }).data;
|
||||
expect(data.fallbackUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle gh CLI process error', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Emit error event
|
||||
mockProcess.emit('error', new Error('spawn gh ENOENT'));
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error', 'spawn gh ENOENT');
|
||||
const data = (result as { data: { fallbackUrl: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should handle non-zero exit code', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.stderr?.emit('data', 'error: some authentication error\n');
|
||||
mockProcess.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { fallbackUrl: string } }).data;
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should include device code in error result if it was extracted before failure', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
mockOpenExternal.mockResolvedValue(undefined);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
// Device code output followed by failure
|
||||
mockProcess.stderr?.emit('data', '! First copy your one-time code: KLMN-7890\n');
|
||||
|
||||
// Wait for async browser opening
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
mockProcess.stderr?.emit('data', 'error: authentication failed\n');
|
||||
mockProcess.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { deviceCode: string; fallbackUrl: string } }).data;
|
||||
expect(data.deviceCode).toBe('KLMN-7890');
|
||||
expect(data.fallbackUrl).toBe('https://github.com/login/device');
|
||||
});
|
||||
|
||||
it('should provide user-friendly error message on process spawn failure', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
const resultPromise = ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
mockProcess.emit('error', new Error('spawn gh ENOENT'));
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
const data = (result as { data: { message: string } }).data;
|
||||
expect(data.message).toContain('Failed to start GitHub CLI');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gh CLI Check Handler', () => {
|
||||
it('should return installed: true when gh CLI is found', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd.includes('which gh') || cmd.includes('where gh')) {
|
||||
return '/usr/local/bin/gh\n';
|
||||
}
|
||||
if (cmd === 'gh --version') {
|
||||
return 'gh version 2.65.0 (2024-01-15)\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const { registerCheckGhCli } = await import('../oauth-handlers');
|
||||
registerCheckGhCli();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkCli', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { installed: boolean; version: string } }).data;
|
||||
expect(data.installed).toBe(true);
|
||||
expect(data.version).toContain('gh version');
|
||||
});
|
||||
|
||||
it('should return installed: false when gh CLI is not found', async () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
const { registerCheckGhCli } = await import('../oauth-handlers');
|
||||
registerCheckGhCli();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkCli', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { installed: boolean } }).data;
|
||||
expect(data.installed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gh Auth Check Handler', () => {
|
||||
it('should return authenticated: true with username when logged in', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'gh auth status') {
|
||||
return 'Logged in to github.com as testuser\n';
|
||||
}
|
||||
if (cmd === 'gh api user --jq .login') {
|
||||
return 'testuser\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const { registerCheckGhAuth } = await import('../oauth-handlers');
|
||||
registerCheckGhAuth();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkAuth', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authenticated: boolean; username: string } }).data;
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.username).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should return authenticated: false when not logged in', async () => {
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('You are not logged into any GitHub hosts');
|
||||
});
|
||||
|
||||
const { registerCheckGhAuth } = await import('../oauth-handlers');
|
||||
registerCheckGhAuth();
|
||||
|
||||
const result = await ipcMain.invokeHandler('github:checkAuth', {});
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: { authenticated: boolean } }).data;
|
||||
expect(data.authenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spawn Arguments', () => {
|
||||
it('should spawn gh with correct auth login arguments', async () => {
|
||||
const mockProcess = createMockProcess();
|
||||
mockSpawn.mockReturnValue(mockProcess);
|
||||
|
||||
const { registerStartGhAuth } = await import('../oauth-handlers');
|
||||
registerStartGhAuth();
|
||||
|
||||
ipcMain.invokeHandler('github:startAuth', {});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
['auth', 'login', '--web', '--scopes', 'repo'],
|
||||
expect.objectContaining({
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository Validation', () => {
|
||||
it('should reject invalid repository format', async () => {
|
||||
const { registerGetGitHubBranches } = await import('../oauth-handlers');
|
||||
registerGetGitHubBranches();
|
||||
|
||||
// Test with injection attempt
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'github:getBranches',
|
||||
{},
|
||||
'owner/repo; rm -rf /',
|
||||
'token'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('success', false);
|
||||
expect(result).toHaveProperty('error', 'Invalid repository format. Expected: owner/repo');
|
||||
});
|
||||
|
||||
it('should accept valid repository format', async () => {
|
||||
mockExecFileSync.mockReturnValue('main\nfeature-branch\n');
|
||||
|
||||
const { registerGetGitHubBranches } = await import('../oauth-handlers');
|
||||
registerGetGitHubBranches();
|
||||
|
||||
const result = await ipcMain.invokeHandler(
|
||||
'github:getBranches',
|
||||
{},
|
||||
'valid-owner/valid-repo',
|
||||
'token'
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty('success', true);
|
||||
const data = (result as { data: string[] }).data;
|
||||
expect(data).toContain('main');
|
||||
expect(data).toContain('feature-branch');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, GitHubIssue } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
|
||||
import type { GitHubAPIIssue, GitHubAPIComment } from './types';
|
||||
|
||||
/**
|
||||
@@ -57,16 +57,32 @@ export function registerGetIssues(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const issues = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated`
|
||||
) as GitHubAPIIssue[];
|
||||
`/repos/${normalizedRepo}/issues?state=${state}&per_page=100&sort=updated`
|
||||
);
|
||||
|
||||
// Ensure issues is an array
|
||||
if (!Array.isArray(issues)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unexpected response format from GitHub API'
|
||||
};
|
||||
}
|
||||
|
||||
// Filter out pull requests
|
||||
const issuesOnly = issues.filter(issue => !issue.pull_request);
|
||||
const issuesOnly = issues.filter((issue: GitHubAPIIssue) => !issue.pull_request);
|
||||
|
||||
const result: GitHubIssue[] = issuesOnly.map(issue =>
|
||||
transformIssue(issue, config.repo)
|
||||
const result: GitHubIssue[] = issuesOnly.map((issue: GitHubAPIIssue) =>
|
||||
transformIssue(issue, normalizedRepo)
|
||||
);
|
||||
|
||||
return { success: true, data: result };
|
||||
@@ -98,12 +114,20 @@ export function registerGetIssue(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const issue = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}`
|
||||
`/repos/${normalizedRepo}/issues/${issueNumber}`
|
||||
) as GitHubAPIIssue;
|
||||
|
||||
const result = transformIssue(issue, config.repo);
|
||||
const result = transformIssue(issue, normalizedRepo);
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
@@ -134,9 +158,17 @@ export function registerGetIssueComments(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
};
|
||||
}
|
||||
|
||||
const comments = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues/${issueNumber}/comments`
|
||||
`/repos/${normalizedRepo}/issues/${issueNumber}/comments`
|
||||
) as GitHubAPIComment[];
|
||||
|
||||
return { success: true, data: comments };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides a simpler OAuth flow than manual PAT creation
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { ipcMain, shell } from 'electron';
|
||||
import { execSync, execFileSync, spawn } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult } from '../../../shared/types';
|
||||
@@ -33,6 +33,71 @@ function isValidGitHubRepo(repo: string): boolean {
|
||||
return GITHUB_REPO_PATTERN.test(repo);
|
||||
}
|
||||
|
||||
// Regex patterns for parsing device code from gh CLI output
|
||||
// Expected format: "! First copy your one-time code: XXXX-XXXX"
|
||||
// Pattern updated to handle different gh CLI versions - supports:
|
||||
// - "one-time code", "code", or "verification code" prefixes
|
||||
// - Hyphen or space separator in the code (XXXX-XXXX or XXXX XXXX)
|
||||
// Note: Separator is REQUIRED to avoid matching 8-char strings without separator
|
||||
const DEVICE_CODE_PATTERN = /(?:one-time code|verification code|code):\s*([A-Z0-9]{4}[-\s][A-Z0-9]{4})/i;
|
||||
|
||||
// GitHub device flow URL pattern
|
||||
const DEVICE_URL_PATTERN = /https:\/\/github\.com\/login\/device/i;
|
||||
|
||||
// Default GitHub device flow URL
|
||||
const GITHUB_DEVICE_URL = 'https://github.com/login/device';
|
||||
|
||||
/**
|
||||
* Parse device code from gh CLI stdout output
|
||||
* Returns the device code (format: XXXX-XXXX) if found, null otherwise
|
||||
* Normalizes space separator to hyphen (GitHub always expects XXXX-XXXX)
|
||||
*/
|
||||
function parseDeviceCode(output: string): string | null {
|
||||
const match = output.match(DEVICE_CODE_PATTERN);
|
||||
if (match && match[1]) {
|
||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||
const normalizedCode = match[1].replace(' ', '-');
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
return normalizedCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse device URL from gh CLI output
|
||||
* Returns the URL if found, or the default GitHub device URL
|
||||
*/
|
||||
function parseDeviceUrl(output: string): string {
|
||||
const match = output.match(DEVICE_URL_PATTERN);
|
||||
if (match) {
|
||||
debugLog('Found device URL in output:', match[0]);
|
||||
return match[0];
|
||||
}
|
||||
// Default to standard GitHub device flow URL
|
||||
return GITHUB_DEVICE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing device flow output from gh CLI
|
||||
*/
|
||||
interface DeviceFlowInfo {
|
||||
deviceCode: string | null;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse both device code and URL from combined gh CLI output
|
||||
* Searches through both stdout and stderr as gh may output to either
|
||||
*/
|
||||
function parseDeviceFlowOutput(stdout: string, stderr: string): DeviceFlowInfo {
|
||||
const combinedOutput = `${stdout}\n${stderr}`;
|
||||
|
||||
return {
|
||||
deviceCode: parseDeviceCode(combinedOutput),
|
||||
authUrl: parseDeviceUrl(combinedOutput)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is installed
|
||||
*/
|
||||
@@ -114,14 +179,31 @@ export function registerCheckGhAuth(): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for GitHub auth start, including device flow information
|
||||
*/
|
||||
interface GitHubAuthStartResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
deviceCode?: string;
|
||||
authUrl?: string;
|
||||
browserOpened?: boolean;
|
||||
/**
|
||||
* Fallback URL provided when browser launch fails.
|
||||
* The frontend should display this URL so users can manually navigate to complete auth.
|
||||
*/
|
||||
fallbackUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start GitHub OAuth flow using gh CLI
|
||||
* This will open the browser for device flow authentication
|
||||
* This will extract the device code from gh CLI output and open the browser
|
||||
* using Electron's shell.openExternal (bypasses macOS child process restrictions)
|
||||
*/
|
||||
export function registerStartGhAuth(): void {
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_START_AUTH,
|
||||
async (): Promise<IPCResult<{ success: boolean; message?: string }>> => {
|
||||
async (): Promise<IPCResult<GitHubAuthStartResult>> => {
|
||||
debugLog('startGitHubAuth handler called');
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
@@ -135,17 +217,64 @@ export function registerStartGhAuth(): void {
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
let deviceCodeExtracted = false;
|
||||
let extractedDeviceCode: string | null = null;
|
||||
let extractedAuthUrl: string = GITHUB_DEVICE_URL;
|
||||
let browserOpenedSuccessfully = false;
|
||||
let extractionInProgress = false;
|
||||
|
||||
// Function to attempt device code extraction and browser opening
|
||||
// Uses mutex pattern to prevent race conditions from concurrent data handlers
|
||||
const tryExtractAndOpenBrowser = async () => {
|
||||
if (deviceCodeExtracted || extractionInProgress) return;
|
||||
extractionInProgress = true;
|
||||
|
||||
const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput);
|
||||
|
||||
if (deviceFlowInfo.deviceCode) {
|
||||
deviceCodeExtracted = true;
|
||||
extractedDeviceCode = deviceFlowInfo.deviceCode;
|
||||
extractedAuthUrl = deviceFlowInfo.authUrl;
|
||||
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
debugLog('Auth URL:', extractedAuthUrl);
|
||||
|
||||
// Open browser using Electron's shell.openExternal
|
||||
// This bypasses macOS child process restrictions that block gh CLI's browser launch
|
||||
try {
|
||||
await shell.openExternal(extractedAuthUrl);
|
||||
browserOpenedSuccessfully = true;
|
||||
debugLog('Browser opened successfully via shell.openExternal');
|
||||
} catch (browserError) {
|
||||
debugLog('Failed to open browser:', browserError instanceof Error ? browserError.message : browserError);
|
||||
browserOpenedSuccessfully = false;
|
||||
// Don't fail here - we'll return the device code so user can manually navigate
|
||||
}
|
||||
|
||||
// Extraction complete - mutex flag stays true to prevent re-extraction
|
||||
// The deviceCodeExtracted flag will prevent future attempts
|
||||
extractionInProgress = false;
|
||||
} else {
|
||||
// No device code found yet, allow next data chunk to try again
|
||||
extractionInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
ghProcess.stdout?.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
output += chunk;
|
||||
debugLog('gh stdout:', chunk);
|
||||
// Try to extract device code as data comes in
|
||||
// Use void to explicitly ignore promise
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.stderr?.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
errorOutput += chunk;
|
||||
debugLog('gh stderr:', chunk);
|
||||
// gh often outputs to stderr, so check there too
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.on('close', (code) => {
|
||||
@@ -154,17 +283,39 @@ export function registerStartGhAuth(): void {
|
||||
debugLog('Full stderr:', errorOutput);
|
||||
|
||||
if (code === 0) {
|
||||
// Success case - include fallbackUrl if browser failed to open
|
||||
// so the user can manually navigate if needed
|
||||
resolve({
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
message: 'Successfully authenticated with GitHub'
|
||||
message: browserOpenedSuccessfully
|
||||
? 'Successfully authenticated with GitHub'
|
||||
: 'Authentication successful. Browser could not be opened automatically.',
|
||||
deviceCode: extractedDeviceCode || undefined,
|
||||
authUrl: extractedAuthUrl,
|
||||
browserOpened: browserOpenedSuccessfully,
|
||||
// Provide fallback URL when browser failed to open
|
||||
fallbackUrl: !browserOpenedSuccessfully ? extractedAuthUrl : undefined
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Even if auth failed, return device code info if we extracted it
|
||||
// This allows user to retry manually with the fallback URL
|
||||
const fallbackUrlForManualAuth = extractedDeviceCode ? extractedAuthUrl : GITHUB_DEVICE_URL;
|
||||
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorOutput || `Authentication failed with exit code ${code}`
|
||||
error: errorOutput || `Authentication failed with exit code ${code}`,
|
||||
data: {
|
||||
success: false,
|
||||
deviceCode: extractedDeviceCode || undefined,
|
||||
authUrl: extractedAuthUrl,
|
||||
browserOpened: browserOpenedSuccessfully,
|
||||
// Always provide fallback URL on failure for manual recovery
|
||||
fallbackUrl: fallbackUrlForManualAuth,
|
||||
message: 'Authentication failed. Please visit the URL manually to complete authentication.'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -173,14 +324,28 @@ export function registerStartGhAuth(): void {
|
||||
debugLog('gh process error:', error.message);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
data: {
|
||||
success: false,
|
||||
browserOpened: false,
|
||||
// Provide fallback URL so user can attempt manual auth
|
||||
fallbackUrl: GITHUB_DEVICE_URL,
|
||||
message: 'Failed to start GitHub CLI. Please visit the URL manually to authenticate.'
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog('Exception in startGitHubAuth:', error instanceof Error ? error.message : error);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
data: {
|
||||
success: false,
|
||||
browserOpened: false,
|
||||
// Provide fallback URL for manual authentication recovery
|
||||
fallbackUrl: GITHUB_DEVICE_URL,
|
||||
message: 'An unexpected error occurred. Please visit the URL manually to authenticate.'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ipcMain } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, GitHubRepository, GitHubSyncStatus } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils';
|
||||
import type { GitHubAPIRepository } from './types';
|
||||
|
||||
/**
|
||||
@@ -33,16 +33,28 @@ export function registerCheckConnection(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize repo reference (handles full URLs, git URLs, etc.)
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
if (!normalizedRepo) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
connected: false,
|
||||
error: 'Invalid repository format. Use owner/repo or GitHub URL.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch repo info
|
||||
const repoData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}`
|
||||
`/repos/${normalizedRepo}`
|
||||
) as { full_name: string; description?: string };
|
||||
|
||||
// Count open issues
|
||||
const issuesData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/issues?state=open&per_page=1`
|
||||
`/repos/${normalizedRepo}/issues?state=open&per_page=1`
|
||||
) as unknown[];
|
||||
|
||||
const openCount = Array.isArray(issuesData) ? issuesData.length : 0;
|
||||
@@ -71,7 +83,7 @@ export function registerCheckConnection(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of GitHub repositories
|
||||
* Get list of GitHub repositories (personal + organization)
|
||||
*/
|
||||
export function registerGetRepositories(): void {
|
||||
ipcMain.handle(
|
||||
@@ -88,9 +100,11 @@ export function registerGetRepositories(): void {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch user's personal + organization repos
|
||||
// affiliation parameter includes: owner, collaborator, organization_member
|
||||
const repos = await githubFetch(
|
||||
config.token,
|
||||
'/user/repos?per_page=100&sort=updated'
|
||||
'/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member'
|
||||
) as GitHubAPIRepository[];
|
||||
|
||||
const result: GitHubRepository[] = repos.map(repo => ({
|
||||
|
||||
@@ -54,6 +54,32 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a GitHub repository reference to owner/repo format
|
||||
* Handles:
|
||||
* - owner/repo (already normalized)
|
||||
* - https://github.com/owner/repo
|
||||
* - https://github.com/owner/repo.git
|
||||
* - git@github.com:owner/repo.git
|
||||
*/
|
||||
export function normalizeRepoReference(repo: string): string {
|
||||
if (!repo) return '';
|
||||
|
||||
// Remove trailing .git if present
|
||||
let normalized = repo.replace(/\.git$/, '');
|
||||
|
||||
// Handle full GitHub URLs
|
||||
if (normalized.startsWith('https://github.com/')) {
|
||||
normalized = normalized.replace('https://github.com/', '');
|
||||
} else if (normalized.startsWith('http://github.com/')) {
|
||||
normalized = normalized.replace('http://github.com/', '');
|
||||
} else if (normalized.startsWith('git@github.com:')) {
|
||||
normalized = normalized.replace('git@github.com:', '');
|
||||
}
|
||||
|
||||
return normalized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the GitHub API
|
||||
*/
|
||||
|
||||
@@ -3,11 +3,45 @@
|
||||
*/
|
||||
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent, BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { IPCResult, IdeationConfig, IdeationGenerationStatus } from '../../../shared/types';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants';
|
||||
import type { IPCResult, IdeationConfig, IdeationGenerationStatus, AppSettings } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import type { AgentManager } from '../../agent';
|
||||
import { debugLog } from '../../../shared/utils/debug-logger';
|
||||
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Read ideation feature settings from the settings file
|
||||
*/
|
||||
function getIdeationFeatureSettings(): { model?: string; thinkingLevel?: string } {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
|
||||
|
||||
// Get ideation-specific settings
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
return {
|
||||
model: featureModels.ideation,
|
||||
thinkingLevel: featureThinking.ideation
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[Ideation Handler] Failed to read feature settings:', error);
|
||||
}
|
||||
|
||||
// Return defaults if settings file doesn't exist or fails to parse
|
||||
return {
|
||||
model: DEFAULT_FEATURE_MODELS.ideation,
|
||||
thinkingLevel: DEFAULT_FEATURE_THINKING.ideation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ideation generation for a project
|
||||
@@ -19,10 +53,20 @@ export function startIdeationGeneration(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): void {
|
||||
// Get feature settings and merge with config
|
||||
const featureSettings = getIdeationFeatureSettings();
|
||||
const configWithSettings: IdeationConfig = {
|
||||
...config,
|
||||
model: config.model || featureSettings.model,
|
||||
thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Ideation Handler] Start generation request:', {
|
||||
projectId,
|
||||
enabledTypes: config.enabledTypes,
|
||||
maxIdeasPerType: config.maxIdeasPerType
|
||||
enabledTypes: configWithSettings.enabledTypes,
|
||||
maxIdeasPerType: configWithSettings.maxIdeasPerType,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
if (!mainWindow) return;
|
||||
@@ -40,11 +84,13 @@ export function startIdeationGeneration(
|
||||
|
||||
debugLog('[Ideation Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path
|
||||
projectPath: project.path,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
// Start ideation generation via agent manager
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, false);
|
||||
agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, false);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -68,6 +114,20 @@ export function refreshIdeationSession(
|
||||
agentManager: AgentManager,
|
||||
mainWindow: BrowserWindow | null
|
||||
): void {
|
||||
// Get feature settings and merge with config
|
||||
const featureSettings = getIdeationFeatureSettings();
|
||||
const configWithSettings: IdeationConfig = {
|
||||
...config,
|
||||
model: config.model || featureSettings.model,
|
||||
thinkingLevel: config.thinkingLevel || featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Ideation Handler] Refresh session request:', {
|
||||
projectId,
|
||||
model: configWithSettings.model,
|
||||
thinkingLevel: configWithSettings.thinkingLevel
|
||||
});
|
||||
|
||||
if (!mainWindow) return;
|
||||
|
||||
const project = projectStore.getProject(projectId);
|
||||
@@ -81,7 +141,7 @@ export function refreshIdeationSession(
|
||||
}
|
||||
|
||||
// Start ideation regeneration with refresh flag
|
||||
agentManager.startIdeationGeneration(projectId, project.path, config, true);
|
||||
agentManager.startIdeationGeneration(projectId, project.path, configWithSettings, true);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
|
||||
@@ -136,8 +136,8 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
// Add process.cwd() as last resort on all platforms
|
||||
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
|
||||
|
||||
// Enable debug logging with AUTO_CLAUDE_DEBUG=1
|
||||
const debug = process.env.AUTO_CLAUDE_DEBUG === '1' || process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
// Enable debug logging with DEBUG=1
|
||||
const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Platform:', process.platform);
|
||||
@@ -164,7 +164,7 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Could not auto-detect Auto Claude source path.');
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Set AUTO_CLAUDE_DEBUG=1 environment variable for detailed path checking.');
|
||||
console.warn('[project-handlers:detectAutoBuildSourcePath] Set DEBUG=1 environment variable for detailed path checking.');
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { ipcMain, app } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
|
||||
import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata, CompetitorAnalysis } from '../../shared/types';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../shared/constants';
|
||||
import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata, CompetitorAnalysis, AppSettings } from '../../shared/types';
|
||||
import type { RoadmapConfig } from '../agent/types';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Read feature settings from the settings file
|
||||
*/
|
||||
function getFeatureSettings(): { model?: string; thinkingLevel?: string } {
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
|
||||
|
||||
// Get roadmap-specific settings
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
return {
|
||||
model: featureModels.roadmap,
|
||||
thinkingLevel: featureThinking.roadmap
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[Roadmap Handler] Failed to read feature settings:', error);
|
||||
}
|
||||
|
||||
// Return defaults if settings file doesn't exist or fails to parse
|
||||
return {
|
||||
model: DEFAULT_FEATURE_MODELS.roadmap,
|
||||
thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register all roadmap-related IPC handlers
|
||||
@@ -138,7 +170,7 @@ export function registerRoadmapHandlers(
|
||||
impact: feature.impact || 'medium',
|
||||
phaseId: feature.phase_id,
|
||||
dependencies: feature.dependencies || [],
|
||||
status: feature.status || 'idea',
|
||||
status: feature.status || 'under_review',
|
||||
acceptanceCriteria: feature.acceptance_criteria || [],
|
||||
userStories: feature.user_stories || [],
|
||||
linkedSpecId: feature.linked_spec_id,
|
||||
@@ -172,10 +204,19 @@ export function registerRoadmapHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_GENERATE,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => {
|
||||
// Get feature settings for roadmap
|
||||
const featureSettings = getFeatureSettings();
|
||||
const config: RoadmapConfig = {
|
||||
model: featureSettings.model,
|
||||
thinkingLevel: featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Roadmap Handler] Generate request:', {
|
||||
projectId,
|
||||
enableCompetitorAnalysis
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
@@ -194,11 +235,19 @@ export function registerRoadmapHandlers(
|
||||
|
||||
debugLog('[Roadmap Handler] Starting agent manager generation:', {
|
||||
projectId,
|
||||
projectPath: project.path
|
||||
projectPath: project.path,
|
||||
config
|
||||
});
|
||||
|
||||
// Start roadmap generation via agent manager
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, false, enableCompetitorAnalysis ?? false);
|
||||
agentManager.startRoadmapGeneration(
|
||||
projectId,
|
||||
project.path,
|
||||
false, // refresh (not a refresh operation)
|
||||
enableCompetitorAnalysis ?? false,
|
||||
refreshCompetitorAnalysis ?? false,
|
||||
config
|
||||
);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -215,7 +264,21 @@ export function registerRoadmapHandlers(
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.ROADMAP_REFRESH,
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean) => {
|
||||
(_, projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => {
|
||||
// Get feature settings for roadmap
|
||||
const featureSettings = getFeatureSettings();
|
||||
const config: RoadmapConfig = {
|
||||
model: featureSettings.model,
|
||||
thinkingLevel: featureSettings.thinkingLevel
|
||||
};
|
||||
|
||||
debugLog('[Roadmap Handler] Refresh request:', {
|
||||
projectId,
|
||||
enableCompetitorAnalysis,
|
||||
refreshCompetitorAnalysis,
|
||||
config
|
||||
});
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return;
|
||||
|
||||
@@ -230,7 +293,14 @@ export function registerRoadmapHandlers(
|
||||
}
|
||||
|
||||
// Start roadmap regeneration with refresh flag
|
||||
agentManager.startRoadmapGeneration(projectId, project.path, true, enableCompetitorAnalysis ?? false);
|
||||
agentManager.startRoadmapGeneration(
|
||||
projectId,
|
||||
project.path,
|
||||
true, // refresh (this is a refresh operation)
|
||||
enableCompetitorAnalysis ?? false,
|
||||
refreshCompetitorAnalysis ?? false,
|
||||
config
|
||||
);
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
@@ -275,7 +345,7 @@ export function registerRoadmapHandlers(
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
features: RoadmapFeature[]
|
||||
roadmapData: Roadmap
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
@@ -294,10 +364,10 @@ export function registerRoadmapHandlers(
|
||||
|
||||
try {
|
||||
const content = readFileSync(roadmapPath, 'utf-8');
|
||||
const roadmap = JSON.parse(content);
|
||||
const existingRoadmap = JSON.parse(content);
|
||||
|
||||
// Transform camelCase features back to snake_case for JSON file
|
||||
roadmap.features = features.map((feature) => ({
|
||||
existingRoadmap.features = roadmapData.features.map((feature) => ({
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
@@ -315,10 +385,10 @@ export function registerRoadmapHandlers(
|
||||
}));
|
||||
|
||||
// Update metadata timestamp
|
||||
roadmap.metadata = roadmap.metadata || {};
|
||||
roadmap.metadata.updated_at = new Date().toISOString();
|
||||
existingRoadmap.metadata = existingRoadmap.metadata || {};
|
||||
existingRoadmap.metadata.updated_at = new Date().toISOString();
|
||||
|
||||
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
|
||||
writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { AgentManager } from '../agent';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { getEffectiveVersion } from '../auto-claude-updater';
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
@@ -47,8 +48,8 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
// Add process.cwd() as last resort on all platforms
|
||||
possiblePaths.push(path.resolve(process.cwd(), 'auto-claude'));
|
||||
|
||||
// Enable debug logging with AUTO_CLAUDE_DEBUG=1
|
||||
const debug = process.env.AUTO_CLAUDE_DEBUG === '1' || process.env.AUTO_CLAUDE_DEBUG === 'true';
|
||||
// Enable debug logging with DEBUG=1
|
||||
const debug = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.warn('[detectAutoBuildSourcePath] Platform:', process.platform);
|
||||
@@ -75,7 +76,7 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
}
|
||||
|
||||
console.warn('[detectAutoBuildSourcePath] Could not auto-detect Auto Claude source path. Please configure manually in settings.');
|
||||
console.warn('[detectAutoBuildSourcePath] Set AUTO_CLAUDE_DEBUG=1 environment variable for detailed path checking.');
|
||||
console.warn('[detectAutoBuildSourcePath] Set DEBUG=1 environment variable for detailed path checking.');
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -93,7 +94,8 @@ export function registerSettingsHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SETTINGS_GET,
|
||||
async (): Promise<IPCResult<AppSettings>> => {
|
||||
let settings = { ...DEFAULT_APP_SETTINGS };
|
||||
let settings: AppSettings = { ...DEFAULT_APP_SETTINGS };
|
||||
let needsSave = false;
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
@@ -104,6 +106,18 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Set agent profile to 'auto' for users who haven't made a selection (one-time)
|
||||
// This ensures new users get the optimized 'auto' profile as the default
|
||||
// while preserving existing user preferences
|
||||
if (!settings._migratedAgentProfileToAuto) {
|
||||
// Only set 'auto' if user hasn't made a selection yet
|
||||
if (!settings.selectedAgentProfile) {
|
||||
settings.selectedAgentProfile = 'auto';
|
||||
}
|
||||
settings._migratedAgentProfileToAuto = true;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// If no manual autoBuildPath is set, try to auto-detect
|
||||
if (!settings.autoBuildPath) {
|
||||
const detectedPath = detectAutoBuildSourcePath();
|
||||
@@ -112,6 +126,16 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Persist migration changes
|
||||
if (needsSave) {
|
||||
try {
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[SETTINGS_GET] Failed to persist migration:', error);
|
||||
// Continue anyway - settings will be migrated in-memory for this session
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: settings as AppSettings };
|
||||
}
|
||||
);
|
||||
@@ -264,7 +288,10 @@ export function registerSettingsHandlers(
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
|
||||
return app.getVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
console.log('[settings-handlers] APP_VERSION returning:', version);
|
||||
return version;
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/con
|
||||
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { AgentManager } from '../../agent';
|
||||
import { fileWatcher } from '../../file-watcher';
|
||||
import { findTaskAndProject } from './shared';
|
||||
@@ -200,6 +201,11 @@ export function registerTaskExecutionHandlers(
|
||||
task.specId
|
||||
);
|
||||
|
||||
// Check if worktree exists - QA needs to run in the worktree where the build happened
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
const worktreeSpecDir = path.join(worktreePath, specsBaseDir, task.specId);
|
||||
const hasWorktree = existsSync(worktreePath);
|
||||
|
||||
if (approved) {
|
||||
// Write approval to QA report
|
||||
const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT);
|
||||
@@ -217,15 +223,60 @@ export function registerTaskExecutionHandlers(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Write feedback for QA fixer
|
||||
const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md');
|
||||
// Reset and discard all changes from worktree merge in main
|
||||
// The worktree still has all changes, so nothing is lost
|
||||
if (hasWorktree) {
|
||||
// Step 1: Unstage all changes
|
||||
const resetResult = spawnSync('git', ['reset', 'HEAD'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (resetResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Unstaged changes in main');
|
||||
}
|
||||
|
||||
// Step 2: Discard all working tree changes (restore to pre-merge state)
|
||||
const checkoutResult = spawnSync('git', ['checkout', '--', '.'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (checkoutResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Discarded working tree changes in main');
|
||||
}
|
||||
|
||||
// Step 3: Clean untracked files that came from the merge
|
||||
const cleanResult = spawnSync('git', ['clean', '-fd'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe'
|
||||
});
|
||||
if (cleanResult.status === 0) {
|
||||
console.log('[TASK_REVIEW] Cleaned untracked files in main');
|
||||
}
|
||||
|
||||
console.log('[TASK_REVIEW] Main branch restored to pre-merge state');
|
||||
}
|
||||
|
||||
// Write feedback for QA fixer - write to WORKTREE spec dir if it exists
|
||||
// The QA process runs in the worktree where the build and implementation_plan.json are
|
||||
const targetSpecDir = hasWorktree ? worktreeSpecDir : specDir;
|
||||
const fixRequestPath = path.join(targetSpecDir, 'QA_FIX_REQUEST.md');
|
||||
|
||||
console.warn('[TASK_REVIEW] Writing QA fix request to:', fixRequestPath);
|
||||
console.warn('[TASK_REVIEW] hasWorktree:', hasWorktree, 'worktreePath:', worktreePath);
|
||||
|
||||
writeFileSync(
|
||||
fixRequestPath,
|
||||
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n`
|
||||
);
|
||||
|
||||
// Restart QA process with dev mode
|
||||
agentManager.startQAProcess(taskId, project.path, task.specId);
|
||||
// Restart QA process - use worktree path if it exists, otherwise main project
|
||||
// The QA process needs to run where the implementation_plan.json with completed subtasks is
|
||||
const qaProjectPath = hasWorktree ? worktreePath : project.path;
|
||||
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
|
||||
@@ -3,12 +3,13 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS } from '../../../shared/constants';
|
||||
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, statSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync, spawn, spawnSync } from 'child_process';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { PythonEnvManager } from '../../python-env-manager';
|
||||
import { getEffectiveSourcePath } from '../../auto-claude-updater';
|
||||
import { getProfileEnv } from '../../rate-limit-detector';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { findPythonCommand, parsePythonCommand } from '../../python-detector';
|
||||
|
||||
/**
|
||||
* Register worktree management handlers
|
||||
@@ -48,14 +49,14 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
// Get base branch (usually main or master)
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
// This matches the Python merge logic which merges into the user's current branch
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
// Try to get the default branch
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
@@ -144,13 +145,13 @@ export function registerWorktreeHandlers(
|
||||
return { success: false, error: 'No worktree found for this task' };
|
||||
}
|
||||
|
||||
// Get base branch
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
@@ -272,6 +273,31 @@ export function registerWorktreeHandlers(
|
||||
const worktreePath = path.join(project.path, '.worktrees', task.specId);
|
||||
debug('Worktree path:', worktreePath, 'exists:', existsSync(worktreePath));
|
||||
|
||||
// Check if changes are already staged (for stage-only mode)
|
||||
if (options?.noCommit) {
|
||||
const stagedResult = spawnSync('git', ['diff', '--staged', '--name-only'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (stagedResult.status === 0 && stagedResult.stdout?.trim()) {
|
||||
const stagedFiles = stagedResult.stdout.trim().split('\n');
|
||||
debug('Changes already staged:', stagedFiles.length, 'files');
|
||||
// Return success - changes are already staged
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
merged: false,
|
||||
message: `Changes already staged (${stagedFiles.length} files). Review with git diff --staged.`,
|
||||
staged: true,
|
||||
alreadyStaged: true,
|
||||
projectPath: project.path
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status before merge
|
||||
try {
|
||||
const gitStatusBefore = execSync('git status --short', { cwd: project.path, encoding: 'utf-8' });
|
||||
@@ -294,7 +320,7 @@ export function registerWorktreeHandlers(
|
||||
args.push('--no-commit');
|
||||
}
|
||||
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
|
||||
debug('Running command:', pythonPath, args.join(' '));
|
||||
debug('Working directory:', sourcePath);
|
||||
|
||||
@@ -310,7 +336,9 @@ export function registerWorktreeHandlers(
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let resolved = false;
|
||||
|
||||
const mergeProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const mergeProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -472,6 +500,21 @@ export function registerWorktreeHandlers(
|
||||
|
||||
debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
|
||||
|
||||
// Read suggested commit message if staging succeeded
|
||||
let suggestedCommitMessage: string | undefined;
|
||||
if (staged) {
|
||||
const commitMsgPath = path.join(specDir, 'suggested_commit_message.txt');
|
||||
try {
|
||||
if (existsSync(commitMsgPath)) {
|
||||
const { readFileSync } = require('fs');
|
||||
suggestedCommitMessage = readFileSync(commitMsgPath, 'utf-8').trim();
|
||||
debug('Read suggested commit message:', suggestedCommitMessage?.substring(0, 100));
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to read suggested commit message:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the status change to implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
try {
|
||||
@@ -503,7 +546,8 @@ export function registerWorktreeHandlers(
|
||||
success: true,
|
||||
message,
|
||||
staged,
|
||||
projectPath: staged ? project.path : undefined
|
||||
projectPath: staged ? project.path : undefined,
|
||||
suggestedCommitMessage
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -622,14 +666,16 @@ export function registerWorktreeHandlers(
|
||||
'--merge-preview'
|
||||
];
|
||||
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || 'python3';
|
||||
const pythonPath = pythonEnvManager.getPythonPath() || findPythonCommand() || 'python';
|
||||
console.warn('[IPC] Running merge preview:', pythonPath, args.join(' '));
|
||||
|
||||
// Get profile environment for consistency
|
||||
const previewProfileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const previewProcess = spawn(pythonPath, args, {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(pythonPath);
|
||||
const previewProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
|
||||
cwd: sourcePath,
|
||||
env: { ...process.env, ...previewProfileEnv, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', DEBUG: 'true' }
|
||||
});
|
||||
@@ -835,13 +881,13 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
}).trim();
|
||||
|
||||
// Get base branch
|
||||
// Get base branch - the current branch in the main project (where changes will be merged)
|
||||
let baseBranch = 'main';
|
||||
try {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', {
|
||||
baseBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
}).trim().replace('origin/', '');
|
||||
}).trim();
|
||||
} catch {
|
||||
baseBranch = 'main';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
|
||||
|
||||
/**
|
||||
@@ -162,14 +164,108 @@ export function registerTerminalHandlers(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE,
|
||||
async (_, profileId: string): Promise<IPCResult> => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH START ==========');
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Requested profile ID:', profileId);
|
||||
|
||||
try {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const previousProfile = profileManager.getActiveProfile();
|
||||
const previousProfileId = previousProfile.id;
|
||||
const newProfile = profileManager.getProfile(profileId);
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Previous profile:', {
|
||||
id: previousProfile.id,
|
||||
name: previousProfile.name,
|
||||
hasOAuthToken: !!previousProfile.oauthToken,
|
||||
isDefault: previousProfile.isDefault
|
||||
});
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] New profile:', newProfile ? {
|
||||
id: newProfile.id,
|
||||
name: newProfile.name,
|
||||
hasOAuthToken: !!newProfile.oauthToken,
|
||||
isDefault: newProfile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
const success = profileManager.setActiveProfile(profileId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] setActiveProfile result:', success);
|
||||
|
||||
if (!success) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
// If the profile actually changed, restart Claude in active terminals
|
||||
// This ensures existing Claude sessions use the new profile's OAuth token
|
||||
const profileChanged = previousProfileId !== profileId;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Profile changed:', profileChanged, {
|
||||
previousProfileId,
|
||||
newProfileId: profileId
|
||||
});
|
||||
|
||||
if (profileChanged) {
|
||||
const activeTerminalIds = terminalManager.getActiveTerminalIds();
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Active terminal IDs:', activeTerminalIds);
|
||||
|
||||
const switchPromises: Promise<void>[] = [];
|
||||
const terminalsInClaudeMode: string[] = [];
|
||||
const terminalsNotInClaudeMode: string[] = [];
|
||||
|
||||
for (const terminalId of activeTerminalIds) {
|
||||
const isClaudeMode = terminalManager.isClaudeMode(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal check:', {
|
||||
terminalId,
|
||||
isClaudeMode
|
||||
});
|
||||
|
||||
if (isClaudeMode) {
|
||||
terminalsInClaudeMode.push(terminalId);
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Queuing terminal for profile switch:', terminalId);
|
||||
switchPromises.push(
|
||||
terminalManager.switchClaudeProfile(terminalId, profileId)
|
||||
.then(() => {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch SUCCESS:', terminalId);
|
||||
})
|
||||
.catch((err) => {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal profile switch FAILED:', terminalId, err);
|
||||
throw err; // Re-throw so Promise.allSettled correctly reports rejections
|
||||
})
|
||||
);
|
||||
} else {
|
||||
terminalsNotInClaudeMode.push(terminalId);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminal summary:', {
|
||||
total: activeTerminalIds.length,
|
||||
inClaudeMode: terminalsInClaudeMode.length,
|
||||
notInClaudeMode: terminalsNotInClaudeMode.length,
|
||||
terminalsToSwitch: terminalsInClaudeMode,
|
||||
terminalsSkipped: terminalsNotInClaudeMode
|
||||
});
|
||||
|
||||
// Wait for all switches to complete (but don't fail the main operation if some fail)
|
||||
if (switchPromises.length > 0) {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Waiting for', switchPromises.length, 'terminal switches...');
|
||||
const results = await Promise.allSettled(switchPromises);
|
||||
const fulfilled = results.filter(r => r.status === 'fulfilled').length;
|
||||
const rejected = results.filter(r => r.status === 'rejected').length;
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Switch results:', {
|
||||
total: results.length,
|
||||
fulfilled,
|
||||
rejected
|
||||
});
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] No terminals in Claude mode to switch');
|
||||
}
|
||||
} else {
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Same profile selected, no terminal switches needed');
|
||||
}
|
||||
|
||||
debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] ========== PROFILE SWITCH COMPLETE ==========');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
debugError('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] EXCEPTION:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set active Claude profile'
|
||||
@@ -208,7 +304,7 @@ export function registerTerminalHandlers(
|
||||
const { mkdirSync, existsSync } = await import('fs');
|
||||
if (!existsSync(profile.configDir)) {
|
||||
mkdirSync(profile.configDir, { recursive: true });
|
||||
console.warn('[IPC] Created config directory:', profile.configDir);
|
||||
debugLog('[IPC] Created config directory:', profile.configDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +313,7 @@ export function registerTerminalHandlers(
|
||||
const terminalId = `claude-login-${profileId}-${Date.now()}`;
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
||||
|
||||
console.warn('[IPC] Initializing Claude profile:', {
|
||||
debugLog('[IPC] Initializing Claude profile:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
configDir: profile.configDir,
|
||||
@@ -231,16 +327,25 @@ export function registerTerminalHandlers(
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use export to ensure the variable persists, then run setup-token
|
||||
// Use platform-specific syntax and escaping for environment variables
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
if (process.platform === 'win32') {
|
||||
// SECURITY: Use Windows-specific escaping for cmd.exe
|
||||
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
|
||||
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
} else {
|
||||
// SECURITY: Use POSIX escaping for bash/zsh
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
}
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
}
|
||||
|
||||
console.warn('[IPC] Sending login command to terminal:', loginCommand);
|
||||
debugLog('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||
// Write the login command to the terminal
|
||||
terminalManager.write(terminalId, `${loginCommand}\r`);
|
||||
@@ -263,7 +368,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to initialize Claude profile:', error);
|
||||
debugError('[IPC] Failed to initialize Claude profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to initialize Claude profile'
|
||||
@@ -284,7 +389,7 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[IPC] Failed to set OAuth token:', error);
|
||||
debugError('[IPC] Failed to set OAuth token:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set OAuth token'
|
||||
@@ -569,5 +674,5 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi
|
||||
mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification);
|
||||
});
|
||||
|
||||
console.warn('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
debugLog('[terminal-handlers] Usage monitor event forwarding initialized');
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(message: string, data?: Record<string, unknown>): void {
|
||||
if (DEBUG) {
|
||||
|
||||
@@ -243,8 +243,8 @@ export class ProjectStore {
|
||||
if (existsSync(specFilePath)) {
|
||||
try {
|
||||
const content = readFileSync(specFilePath, 'utf-8');
|
||||
// Extract first paragraph after "## Overview"
|
||||
const overviewMatch = content.match(/## Overview\s*\n\n([^\n#]+)/);
|
||||
// Extract first paragraph after "## Overview" - handle both with and without blank line
|
||||
const overviewMatch = content.match(/## Overview\s*\n+([^\n#]+)/);
|
||||
if (overviewMatch) {
|
||||
description = overviewMatch[1].trim();
|
||||
}
|
||||
@@ -258,6 +258,42 @@ export class ProjectStore {
|
||||
description = plan.description;
|
||||
}
|
||||
|
||||
// Fallback: read description from requirements.json if still not found
|
||||
if (!description) {
|
||||
const requirementsPath = path.join(specPath, AUTO_BUILD_PATHS.REQUIREMENTS);
|
||||
if (existsSync(requirementsPath)) {
|
||||
try {
|
||||
const reqContent = readFileSync(requirementsPath, 'utf-8');
|
||||
const requirements = JSON.parse(reqContent);
|
||||
if (requirements.task_description) {
|
||||
// Extract a clean summary from task_description (first line or first ~200 chars)
|
||||
const taskDesc = requirements.task_description;
|
||||
const firstLine = taskDesc.split('\n')[0].trim();
|
||||
// If the first line is a title like "Investigate GitHub Issue #36", use the next meaningful line
|
||||
if (firstLine.toLowerCase().startsWith('investigate') && taskDesc.includes('\n\n')) {
|
||||
const sections = taskDesc.split('\n\n');
|
||||
// Find the first paragraph that's not a title
|
||||
for (const section of sections) {
|
||||
const trimmed = section.trim();
|
||||
// Skip headers and short lines
|
||||
if (trimmed.startsWith('#') || trimmed.length < 20) continue;
|
||||
// Skip the "Please analyze" instruction at the end
|
||||
if (trimmed.startsWith('Please analyze')) continue;
|
||||
description = trimmed.substring(0, 200).split('\n')[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If still no description, use a shortened version of task_description
|
||||
if (!description) {
|
||||
description = firstLine.substring(0, 150);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read task metadata
|
||||
const metadataPath = path.join(specPath, 'task_metadata.json');
|
||||
let metadata: TaskMetadata | undefined;
|
||||
@@ -290,11 +326,30 @@ export class ProjectStore {
|
||||
const stagedInMainProject = planWithStaged?.stagedInMainProject;
|
||||
const stagedAt = planWithStaged?.stagedAt;
|
||||
|
||||
// Determine title - check if feature looks like a spec ID (e.g., "054-something-something")
|
||||
let title = plan?.feature || plan?.title || dir.name;
|
||||
const looksLikeSpecId = /^\d{3}-/.test(title);
|
||||
if (looksLikeSpecId && existsSync(specFilePath)) {
|
||||
try {
|
||||
const specContent = readFileSync(specFilePath, 'utf-8');
|
||||
// Extract title from first # line, handling patterns like:
|
||||
// "# Quick Spec: Title" -> "Title"
|
||||
// "# Specification: Title" -> "Title"
|
||||
// "# Title" -> "Title"
|
||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
// Keep the original title on error
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
specId: dir.name,
|
||||
projectId,
|
||||
title: plan?.feature || plan?.title || dir.name,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
reviewReason,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* Detect and return the best available Python command.
|
||||
* Tries multiple candidates and returns the first one that works with Python 3.
|
||||
*
|
||||
* @returns The Python command to use, or null if none found
|
||||
*/
|
||||
export function findPythonCommand(): string | null {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// On Windows, try py launcher first (most reliable), then python, then python3
|
||||
// On Unix, try python3 first, then python
|
||||
const candidates = isWindows
|
||||
? ['py -3', 'python', 'python3', 'py']
|
||||
: ['python3', 'python'];
|
||||
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
const version = execSync(`${cmd} --version`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
windowsHide: true
|
||||
}).toString();
|
||||
|
||||
if (version.includes('Python 3')) {
|
||||
return cmd;
|
||||
}
|
||||
} catch {
|
||||
// Command not found or errored, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to platform-specific default
|
||||
return isWindows ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default Python command for the current platform.
|
||||
* This is a synchronous fallback that doesn't test if Python actually exists.
|
||||
*
|
||||
* @returns The default Python command for this platform
|
||||
*/
|
||||
export function getDefaultPythonCommand(): string {
|
||||
return process.platform === 'win32' ? 'python' : 'python3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Python command string into command and base arguments.
|
||||
* Handles space-separated commands like "py -3".
|
||||
*
|
||||
* @param pythonPath - The Python command string (e.g., "python3", "py -3")
|
||||
* @returns Tuple of [command, baseArgs] ready for use with spawn()
|
||||
*/
|
||||
export function parsePythonCommand(pythonPath: string): [string, string[]] {
|
||||
const parts = pythonPath.split(' ');
|
||||
const command = parts[0];
|
||||
const baseArgs = parts.slice(1);
|
||||
return [command, baseArgs];
|
||||
}
|
||||
@@ -37,16 +37,11 @@ export class PythonEnvManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get the path to pip in the venv
|
||||
* Returns null - we use python -m pip instead for better compatibility
|
||||
* @deprecated Use getVenvPythonPath() with -m pip instead
|
||||
*/
|
||||
private getVenvPipPath(): string | null {
|
||||
if (!this.autoBuildSourcePath) return null;
|
||||
|
||||
const venvPip =
|
||||
process.platform === 'win32'
|
||||
? path.join(this.autoBuildSourcePath, '.venv', 'Scripts', 'pip.exe')
|
||||
: path.join(this.autoBuildSourcePath, '.venv', 'bin', 'pip');
|
||||
|
||||
return venvPip;
|
||||
return null; // Not used - we use python -m pip
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,16 +176,54 @@ export class PythonEnvManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies from requirements.txt
|
||||
* Bootstrap pip in the venv using ensurepip
|
||||
*/
|
||||
private async bootstrapPip(): Promise<boolean> {
|
||||
const venvPython = this.getVenvPythonPath();
|
||||
if (!venvPython || !existsSync(venvPython)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn('[PythonEnvManager] Bootstrapping pip...');
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(venvPython, ['-m', 'ensurepip'], {
|
||||
cwd: this.autoBuildSourcePath!,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.warn('[PythonEnvManager] Pip bootstrapped successfully');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error('[PythonEnvManager] Failed to bootstrap pip:', stderr);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error('[PythonEnvManager] Error bootstrapping pip:', err);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies from requirements.txt using python -m pip
|
||||
*/
|
||||
private async installDeps(): Promise<boolean> {
|
||||
if (!this.autoBuildSourcePath) return false;
|
||||
|
||||
const venvPip = this.getVenvPipPath();
|
||||
const venvPython = this.getVenvPythonPath();
|
||||
const requirementsPath = path.join(this.autoBuildSourcePath, 'requirements.txt');
|
||||
|
||||
if (!venvPip || !existsSync(venvPip)) {
|
||||
this.emit('error', 'Pip not found in virtual environment');
|
||||
if (!venvPython || !existsSync(venvPython)) {
|
||||
this.emit('error', 'Python not found in virtual environment');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -199,11 +232,15 @@ export class PythonEnvManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bootstrap pip first if needed
|
||||
await this.bootstrapPip();
|
||||
|
||||
this.emit('status', 'Installing Python dependencies (this may take a minute)...');
|
||||
console.warn('[PythonEnvManager] Installing dependencies from:', requirementsPath);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(venvPip, ['install', '-r', requirementsPath], {
|
||||
// Use python -m pip for better compatibility across Python versions
|
||||
const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], {
|
||||
cwd: this.autoBuildSourcePath!,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export class TaskLogService extends EventEmitter {
|
||||
try {
|
||||
const content = readFileSync(logFile, 'utf-8');
|
||||
const logs = JSON.parse(content) as TaskLogs;
|
||||
this.logCache.set(specDir, logs);
|
||||
return logs;
|
||||
} catch (error) {
|
||||
// JSON parse error - file may be mid-write, return cached version if available
|
||||
|
||||
@@ -4,11 +4,12 @@ import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
|
||||
* Service for generating terminal names from commands using Claude AI
|
||||
*/
|
||||
export class TerminalNameGenerator extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor() {
|
||||
@@ -130,7 +132,9 @@ export class TerminalNameGenerator extends EventEmitter {
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: autoBuildSource,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import * as OutputParser from './output-parser';
|
||||
import * as SessionHandler from './session-handler';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape';
|
||||
import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
@@ -92,9 +94,11 @@ export function handleOAuthToken(
|
||||
console.warn('[ClaudeIntegration] OAuth token detected, length:', token.length);
|
||||
|
||||
const email = OutputParser.extractEmail(terminal.outputBuffer);
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+)-/);
|
||||
// Match both custom profiles (profile-123456) and the default profile
|
||||
const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+|default)-/);
|
||||
|
||||
if (profileIdMatch) {
|
||||
// Save to specific profile (profile login terminal)
|
||||
const profileId = profileIdMatch[1];
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const success = profileManager.setProfileToken(profileId, token, email || undefined);
|
||||
@@ -116,16 +120,56 @@ export function handleOAuthToken(
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration] OAuth token detected but not in a profile login terminal');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Token detected but no profile associated with this terminal',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
// No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.)
|
||||
console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile');
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
// Defensive null check for active profile
|
||||
if (!activeProfile) {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found');
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: undefined,
|
||||
email,
|
||||
success: false,
|
||||
message: 'No active profile found',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined);
|
||||
|
||||
if (success) {
|
||||
console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name);
|
||||
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile.id,
|
||||
email,
|
||||
success: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
} else {
|
||||
console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name);
|
||||
const win = getWindow();
|
||||
if (win) {
|
||||
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId: activeProfile?.id,
|
||||
email,
|
||||
success: false,
|
||||
message: 'Failed to save token to active profile',
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +205,11 @@ export function invokeClaude(
|
||||
getWindow: WindowGetter,
|
||||
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
|
||||
): void {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE START ==========');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Requested profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
|
||||
|
||||
terminal.isClaudeMode = true;
|
||||
terminal.claudeSessionId = undefined;
|
||||
|
||||
@@ -175,31 +224,70 @@ export function invokeClaude(
|
||||
const previousProfileId = terminal.claudeProfileId;
|
||||
terminal.claudeProfileId = activeProfile?.id;
|
||||
|
||||
const cwdCommand = cwd ? `cd "${cwd}" && ` : '';
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Profile resolution:', {
|
||||
previousProfileId,
|
||||
newProfileId: activeProfile?.id,
|
||||
profileName: activeProfile?.name,
|
||||
hasOAuthToken: !!activeProfile?.oauthToken,
|
||||
isDefault: activeProfile?.isDefault
|
||||
});
|
||||
|
||||
// Use safe shell escaping to prevent command injection
|
||||
const cwdCommand = buildCdCommand(cwd);
|
||||
const needsEnvOverride = profileId && profileId !== previousProfileId;
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
|
||||
profileIdProvided: !!profileId,
|
||||
previousProfileId,
|
||||
needsEnvOverride
|
||||
});
|
||||
|
||||
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
|
||||
const token = profileManager.getProfileToken(activeProfile.id);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', {
|
||||
hasToken: !!token,
|
||||
tokenLength: token?.length
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile);
|
||||
fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 });
|
||||
|
||||
terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`);
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', activeProfile.name, '(via secure temp file)');
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// - HISTFILE= disables history file writing for the current command
|
||||
// - HISTCONTROL=ignorespace causes commands starting with space to be ignored
|
||||
// - Leading space ensures the command is ignored even if HISTCONTROL was already set
|
||||
// - Uses subshell (...) to isolate environment changes
|
||||
// This prevents temp file paths from appearing in shell history
|
||||
const command = `clear && ${cwdCommand} HISTFILE= HISTCONTROL=ignorespace bash -c 'source "${tempFile}" && rm -f "${tempFile}" && exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) ==========');
|
||||
return;
|
||||
} else if (activeProfile.configDir) {
|
||||
terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`);
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir);
|
||||
// Clear terminal and run command without adding to shell history:
|
||||
// Same history-disabling technique as temp file method above
|
||||
// SECURITY: Use escapeShellArg for configDir to prevent command injection
|
||||
// Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string
|
||||
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
|
||||
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} bash -c 'exec claude'\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
|
||||
terminal.pty.write(command);
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) ==========');
|
||||
return;
|
||||
} else {
|
||||
debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile');
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
console.warn('[ClaudeIntegration] Using Claude profile:', activeProfile.name, '(from terminal environment)');
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
|
||||
}
|
||||
|
||||
terminal.pty.write(`${cwdCommand}claude\r`);
|
||||
const command = `${cwdCommand}claude\r`;
|
||||
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
|
||||
terminal.pty.write(command);
|
||||
|
||||
if (activeProfile) {
|
||||
profileManager.markProfileUsed(activeProfile.id);
|
||||
@@ -220,6 +308,8 @@ export function invokeClaude(
|
||||
if (projectPath) {
|
||||
onSessionCapture(terminal.id, projectPath, startTime);
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) ==========');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +324,8 @@ export function resumeClaude(
|
||||
|
||||
let command: string;
|
||||
if (sessionId) {
|
||||
command = `claude --resume "${sessionId}"`;
|
||||
// SECURITY: Escape sessionId to prevent command injection
|
||||
command = `claude --resume ${escapeShellArg(sessionId)}`;
|
||||
terminal.claudeSessionId = sessionId;
|
||||
} else {
|
||||
command = 'claude --continue';
|
||||
@@ -248,6 +339,103 @@ export function resumeClaude(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitConfig {
|
||||
/** Maximum time to wait for Claude to exit (ms) */
|
||||
timeout?: number;
|
||||
/** Interval between checks (ms) */
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for Claude to exit
|
||||
*/
|
||||
interface WaitForExitResult {
|
||||
/** Whether Claude exited successfully */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Whether the operation timed out */
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell prompt patterns that indicate Claude has exited and shell is ready
|
||||
* These patterns match common shell prompts across bash, zsh, fish, etc.
|
||||
*/
|
||||
const SHELL_PROMPT_PATTERNS = [
|
||||
/[$%#>❯]\s*$/m, // Common prompt endings: $, %, #, >, ❯
|
||||
/\w+@[\w.-]+[:\s]/, // user@hostname: format
|
||||
/^\s*\S+\s*[$%#>❯]\s*$/m, // hostname/path followed by prompt char
|
||||
/\(.*\)\s*[$%#>❯]\s*$/m, // (venv) or (branch) followed by prompt
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for Claude to exit by monitoring terminal output for shell prompt
|
||||
*
|
||||
* Instead of using fixed delays, this monitors the terminal's outputBuffer
|
||||
* for patterns indicating that Claude has exited and the shell prompt is visible.
|
||||
*/
|
||||
async function waitForClaudeExit(
|
||||
terminal: TerminalProcess,
|
||||
config: WaitForExitConfig = {}
|
||||
): Promise<WaitForExitResult> {
|
||||
const { timeout = 5000, pollInterval = 100 } = config;
|
||||
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Waiting for Claude to exit...');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Config:', { timeout, pollInterval });
|
||||
|
||||
// Capture current buffer length to detect new output
|
||||
const initialBufferLength = terminal.outputBuffer.length;
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkForPrompt = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Check for timeout
|
||||
if (elapsed >= timeout) {
|
||||
console.warn('[ClaudeIntegration:waitForClaudeExit] Timeout waiting for Claude to exit after', timeout, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Timeout reached, Claude may not have exited cleanly');
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Timeout waiting for Claude to exit after ${timeout}ms`,
|
||||
timedOut: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get new output since we started waiting
|
||||
const newOutput = terminal.outputBuffer.slice(initialBufferLength);
|
||||
|
||||
// Check if we can see a shell prompt in the new output
|
||||
for (const pattern of SHELL_PROMPT_PATTERNS) {
|
||||
if (pattern.test(newOutput)) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Shell prompt detected after', elapsed, 'ms');
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] Matched pattern:', pattern.toString());
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if isClaudeMode was cleared (set by other handlers)
|
||||
if (!terminal.isClaudeMode) {
|
||||
debugLog('[ClaudeIntegration:waitForClaudeExit] isClaudeMode flag cleared after', elapsed, 'ms');
|
||||
resolve({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(checkForPrompt, pollInterval);
|
||||
};
|
||||
|
||||
// Start checking
|
||||
checkForPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch terminal to a different Claude profile
|
||||
*/
|
||||
@@ -258,27 +446,95 @@ export async function switchClaudeProfile(
|
||||
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void,
|
||||
clearRateLimitCallback: (terminalId: string) => void
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Always-on tracing
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Called for terminal:', terminal.id, '| profileId:', profileId);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Terminal state: isClaudeMode=', terminal.isClaudeMode);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE START ==========');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal ID:', terminal.id);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile ID:', profileId);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal state:', {
|
||||
isClaudeMode: terminal.isClaudeMode,
|
||||
currentProfileId: terminal.claudeProfileId,
|
||||
claudeSessionId: terminal.claudeSessionId,
|
||||
projectPath: terminal.projectPath,
|
||||
cwd: terminal.cwd
|
||||
});
|
||||
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const profile = profileManager.getProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Target profile:', profile ? {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
hasOAuthToken: !!profile.oauthToken,
|
||||
isDefault: profile.isDefault
|
||||
} : 'NOT FOUND');
|
||||
|
||||
if (!profile) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Profile not found, aborting');
|
||||
return { success: false, error: 'Profile not found' };
|
||||
}
|
||||
|
||||
console.warn('[ClaudeIntegration] Switching to Claude profile:', profile.name);
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Switching to profile:', profile.name);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Switching to Claude profile:', profile.name);
|
||||
|
||||
if (terminal.isClaudeMode) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Sending exit commands (Ctrl+C, /exit)');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal is in Claude mode, sending exit commands');
|
||||
|
||||
// Send Ctrl+C to interrupt any ongoing operation
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending Ctrl+C (\\x03)');
|
||||
terminal.pty.write('\x03');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait briefly for Ctrl+C to take effect before sending /exit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Send /exit command
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Sending /exit command');
|
||||
terminal.pty.write('/exit\r');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Wait for Claude to actually exit by monitoring for shell prompt
|
||||
const exitResult = await waitForClaudeExit(terminal, { timeout: 5000, pollInterval: 100 });
|
||||
|
||||
if (exitResult.timedOut) {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Timed out waiting for Claude to exit, proceeding with caution');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Exit timeout - terminal may be in inconsistent state');
|
||||
|
||||
// Even on timeout, we'll try to proceed but log the warning
|
||||
// The alternative would be to abort, but that could leave users stuck
|
||||
// If this becomes a problem, we could add retry logic or abort option
|
||||
} else if (!exitResult.success) {
|
||||
console.error('[ClaudeIntegration:switchClaudeProfile] Failed to exit Claude:', exitResult.error);
|
||||
debugError('[ClaudeIntegration:switchClaudeProfile] Exit failed:', exitResult.error);
|
||||
// Continue anyway - the /exit command was sent
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Claude exited successfully');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Claude exited, ready to switch profile');
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] NOT in Claude mode, skipping exit commands');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Terminal NOT in Claude mode, skipping exit commands');
|
||||
}
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Clearing rate limit state for terminal');
|
||||
clearRateLimitCallback(terminal.id);
|
||||
|
||||
const projectPath = terminal.projectPath || terminal.cwd;
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath);
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with new profile:', {
|
||||
terminalId: terminal.id,
|
||||
projectPath,
|
||||
profileId
|
||||
});
|
||||
invokeClaudeCallback(terminal.id, projectPath, profileId);
|
||||
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager');
|
||||
profileManager.setActiveProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration:switchClaudeProfile] COMPLETE');
|
||||
debugLog('[ClaudeIntegration:switchClaudeProfile] ========== SWITCH PROFILE COMPLETE ==========');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import * as net from 'net';
|
||||
import * as fs from 'fs';
|
||||
import * as pty from 'node-pty';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
|
||||
const SOCKET_PATH =
|
||||
process.platform === 'win32'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles low-level PTY process creation and lifecycle
|
||||
*/
|
||||
|
||||
import * as pty from 'node-pty';
|
||||
import * as pty from '@lydell/node-pty';
|
||||
import * as os from 'os';
|
||||
import type { TerminalProcess, WindowGetter } from './types';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as pty from 'node-pty';
|
||||
import type * as pty from '@lydell/node-pty';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,12 @@ import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector';
|
||||
import { findPythonCommand, parsePythonCommand } from './python-detector';
|
||||
|
||||
/**
|
||||
* Debug logging - only logs when AUTO_CLAUDE_DEBUG env var is set
|
||||
* Debug logging - only logs when DEBUG=true or in development mode
|
||||
*/
|
||||
const DEBUG = process.env.AUTO_CLAUDE_DEBUG === 'true' || process.env.AUTO_CLAUDE_DEBUG === '1';
|
||||
const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
@@ -20,7 +21,8 @@ function debug(...args: unknown[]): void {
|
||||
* Service for generating task titles from descriptions using Claude AI
|
||||
*/
|
||||
export class TitleGenerator extends EventEmitter {
|
||||
private pythonPath: string = 'python3';
|
||||
// Auto-detect Python command on initialization
|
||||
private pythonPath: string = findPythonCommand() || 'python';
|
||||
private autoBuildSourcePath: string = '';
|
||||
|
||||
constructor() {
|
||||
@@ -129,7 +131,9 @@ export class TitleGenerator extends EventEmitter {
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const childProcess = spawn(this.pythonPath, ['-c', script], {
|
||||
// Parse Python command to handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.pythonPath);
|
||||
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, '-c', script], {
|
||||
cwd: autoBuildSource,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -74,9 +74,12 @@ export function downloadFile(
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(destPath);
|
||||
|
||||
// GitHub API URLs need the GitHub Accept header to get a redirect to the actual file
|
||||
// Non-API URLs (CDN, direct downloads) use octet-stream
|
||||
const isGitHubApi = url.includes('api.github.com');
|
||||
const headers = {
|
||||
'User-Agent': 'Auto-Claude-UI',
|
||||
'Accept': 'application/octet-stream'
|
||||
'Accept': isGitHubApi ? 'application/vnd.github+json' : 'application/octet-stream'
|
||||
};
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
import { GITHUB_CONFIG } from './config';
|
||||
import { fetchJson } from './http-client';
|
||||
import { getBundledVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { getEffectiveVersion, parseVersionFromTag, compareVersions } from './version-manager';
|
||||
import { GitHubRelease, AutoBuildUpdateCheck } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
// Cache for the latest release info (used by download)
|
||||
let cachedLatestRelease: GitHubRelease | null = null;
|
||||
@@ -35,7 +36,9 @@ export function clearCachedRelease(): void {
|
||||
* Check GitHub Releases for the latest version
|
||||
*/
|
||||
export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
const currentVersion = getBundledVersion();
|
||||
// Use effective version which accounts for source updates
|
||||
const currentVersion = getEffectiveVersion();
|
||||
debugLog('[UpdateCheck] Current effective version:', currentVersion);
|
||||
|
||||
try {
|
||||
// Fetch latest release from GitHub Releases API
|
||||
@@ -47,9 +50,11 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
|
||||
// Parse version from tag (e.g., "v1.2.0" -> "1.2.0")
|
||||
const latestVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[UpdateCheck] Latest version:', latestVersion);
|
||||
|
||||
// Compare versions
|
||||
const updateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
||||
debugLog('[UpdateCheck] Update available:', updateAvailable);
|
||||
|
||||
return {
|
||||
updateAvailable,
|
||||
@@ -61,6 +66,7 @@ export async function checkForUpdates(): Promise<AutoBuildUpdateCheck> {
|
||||
} catch (error) {
|
||||
// Clear cache on error
|
||||
clearCachedRelease();
|
||||
debugLog('[UpdateCheck] Error:', error instanceof Error ? error.message : error);
|
||||
|
||||
return {
|
||||
updateAvailable: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getUpdateCachePath, getUpdateTargetPath } from './path-resolver';
|
||||
import { extractTarball, copyDirectoryRecursive, preserveFiles, restoreFiles, cleanTargetDirectory } from './file-operations';
|
||||
import { getCachedRelease, setCachedRelease, clearCachedRelease } from './update-checker';
|
||||
import { GitHubRelease, AutoBuildUpdateResult, UpdateProgressCallback, UpdateMetadata } from './types';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Download and apply the latest auto-claude update from GitHub Releases
|
||||
@@ -25,6 +26,9 @@ export async function downloadAndApplyUpdate(
|
||||
): Promise<AutoBuildUpdateResult> {
|
||||
const cachePath = getUpdateCachePath();
|
||||
|
||||
debugLog('[Update] Starting update process...');
|
||||
debugLog('[Update] Cache path:', cachePath);
|
||||
|
||||
try {
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -34,19 +38,26 @@ export async function downloadAndApplyUpdate(
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(cachePath)) {
|
||||
mkdirSync(cachePath, { recursive: true });
|
||||
debugLog('[Update] Created cache directory');
|
||||
}
|
||||
|
||||
// Get release info (use cache or fetch fresh)
|
||||
let release = getCachedRelease();
|
||||
if (!release) {
|
||||
const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`;
|
||||
debugLog('[Update] Fetching release info from:', releaseUrl);
|
||||
release = await fetchJson<GitHubRelease>(releaseUrl);
|
||||
setCachedRelease(release);
|
||||
} else {
|
||||
debugLog('[Update] Using cached release info');
|
||||
}
|
||||
|
||||
// Use the release tarball URL
|
||||
const tarballUrl = release.tarball_url;
|
||||
// Use explicit tag reference URL to avoid HTTP 300 when branch/tag names collide
|
||||
// See: https://github.com/AndyMik90/Auto-Claude/issues/78
|
||||
const tarballUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/tarball/refs/tags/${release.tag_name}`;
|
||||
const releaseVersion = parseVersionFromTag(release.tag_name);
|
||||
debugLog('[Update] Release version:', releaseVersion);
|
||||
debugLog('[Update] Tarball URL:', tarballUrl);
|
||||
|
||||
const tarballPath = path.join(cachePath, 'auto-claude-update.tar.gz');
|
||||
const extractPath = path.join(cachePath, 'extracted');
|
||||
@@ -63,6 +74,8 @@ export async function downloadAndApplyUpdate(
|
||||
message: 'Downloading update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Starting download to:', tarballPath);
|
||||
|
||||
// Download the tarball
|
||||
await downloadFile(tarballUrl, tarballPath, (percent) => {
|
||||
onProgress?.({
|
||||
@@ -72,14 +85,20 @@ export async function downloadAndApplyUpdate(
|
||||
});
|
||||
});
|
||||
|
||||
debugLog('[Update] Download complete');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'extracting',
|
||||
message: 'Extracting update...'
|
||||
});
|
||||
|
||||
debugLog('[Update] Extracting to:', extractPath);
|
||||
|
||||
// Extract the tarball
|
||||
await extractTarball(tarballPath, extractPath);
|
||||
|
||||
debugLog('[Update] Extraction complete');
|
||||
|
||||
// Find the auto-claude folder in extracted content
|
||||
// GitHub tarballs have a root folder like "owner-repo-hash/"
|
||||
const extractedDirs = readdirSync(extractPath);
|
||||
@@ -96,6 +115,7 @@ export async function downloadAndApplyUpdate(
|
||||
|
||||
// Determine where to install the update
|
||||
const targetPath = getUpdateTargetPath();
|
||||
debugLog('[Update] Target install path:', targetPath);
|
||||
|
||||
// Backup existing source (if in dev mode)
|
||||
const backupPath = path.join(cachePath, 'backup');
|
||||
@@ -104,11 +124,14 @@ export async function downloadAndApplyUpdate(
|
||||
rmSync(backupPath, { recursive: true, force: true });
|
||||
}
|
||||
// Simple copy for backup
|
||||
debugLog('[Update] Creating backup at:', backupPath);
|
||||
copyDirectoryRecursive(targetPath, backupPath);
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
debugLog('[Update] Applying update...');
|
||||
await applyUpdate(targetPath, autoBuildSource);
|
||||
debugLog('[Update] Update applied successfully');
|
||||
|
||||
// Write update metadata
|
||||
const metadata: UpdateMetadata = {
|
||||
@@ -132,14 +155,26 @@ export async function downloadAndApplyUpdate(
|
||||
message: `Updated to version ${releaseVersion}`
|
||||
});
|
||||
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE SUCCESSFUL');
|
||||
debugLog('[Update] New version:', releaseVersion);
|
||||
debugLog('[Update] Target path:', targetPath);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: releaseVersion
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Update failed';
|
||||
debugLog('[Update] ============================================');
|
||||
debugLog('[Update] UPDATE FAILED');
|
||||
debugLog('[Update] Error:', errorMessage);
|
||||
debugLog('[Update] ============================================');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'error',
|
||||
message: error instanceof Error ? error.message : 'Update failed'
|
||||
message: errorMessage
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,17 +3,85 @@
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { UpdateMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Get the current app/framework version
|
||||
* Get the current app/framework version from package.json
|
||||
*
|
||||
* Uses app.getVersion() (from package.json) as the single source of truth.
|
||||
* Both the Electron app and auto-claude framework share the same version.
|
||||
* Uses app.getVersion() (from package.json) as the base version.
|
||||
*/
|
||||
export function getBundledVersion(): string {
|
||||
return app.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective version - accounts for source updates
|
||||
*
|
||||
* Returns the updated source version if an update has been applied,
|
||||
* otherwise returns the bundled version.
|
||||
*/
|
||||
export function getEffectiveVersion(): string {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Build list of paths to check for update metadata
|
||||
const metadataPaths: string[] = [];
|
||||
|
||||
if (app.isPackaged) {
|
||||
// Production: check userData override path
|
||||
metadataPaths.push(
|
||||
path.join(app.getPath('userData'), 'auto-claude-source', '.update-metadata.json')
|
||||
);
|
||||
} else {
|
||||
// Development: check the actual source paths where updates are written
|
||||
const possibleSourcePaths = [
|
||||
path.join(app.getAppPath(), '..', 'auto-claude'),
|
||||
path.join(app.getAppPath(), '..', '..', 'auto-claude'),
|
||||
path.join(process.cwd(), 'auto-claude'),
|
||||
path.join(process.cwd(), '..', 'auto-claude')
|
||||
];
|
||||
|
||||
for (const sourcePath of possibleSourcePaths) {
|
||||
metadataPaths.push(path.join(sourcePath, '.update-metadata.json'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.log('[Version] Checking metadata paths:', metadataPaths);
|
||||
}
|
||||
|
||||
// Check each path for metadata
|
||||
for (const metadataPath of metadataPaths) {
|
||||
const exists = existsSync(metadataPath);
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Checking ${metadataPath}: ${exists ? 'EXISTS' : 'not found'}`);
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as UpdateMetadata;
|
||||
if (metadata.version) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Found metadata version: ${metadata.version}`);
|
||||
}
|
||||
return metadata.version;
|
||||
}
|
||||
} catch (e) {
|
||||
if (isDebug) {
|
||||
console.log(`[Version] Error reading metadata: ${e}`);
|
||||
}
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundledVersion = app.getVersion();
|
||||
if (isDebug) {
|
||||
console.log(`[Version] No metadata found, using bundled version: ${bundledVersion}`);
|
||||
}
|
||||
return bundledVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version from GitHub release tag
|
||||
* Handles tags like "v1.2.0", "1.2.0", "v1.2.0-beta"
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface RoadmapAPI {
|
||||
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;
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean) => void;
|
||||
stopRoadmap: (projectId: string) => Promise<IPCResult>;
|
||||
updateFeatureStatus: (
|
||||
projectId: string,
|
||||
@@ -58,11 +58,11 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
|
||||
saveRoadmap: (projectId: string, roadmap: Roadmap): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_SAVE, projectId, roadmap),
|
||||
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_GENERATE, projectId, enableCompetitorAnalysis),
|
||||
generateRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_GENERATE, projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis),
|
||||
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis),
|
||||
refreshRoadmap: (projectId: string, enableCompetitorAnalysis?: boolean, refreshCompetitorAnalysis?: boolean): void =>
|
||||
sendIpc(IPC_CHANNELS.ROADMAP_REFRESH, projectId, enableCompetitorAnalysis, refreshCompetitorAnalysis),
|
||||
|
||||
stopRoadmap: (projectId: string): Promise<IPCResult> =>
|
||||
invokeIpc(IPC_CHANNELS.ROADMAP_STOP, projectId),
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './components/ui/tooltip';
|
||||
import { Sidebar, type SidebarView } from './components/Sidebar';
|
||||
import { KanbanBoard } from './components/KanbanBoard';
|
||||
import { TaskDetailPanel } from './components/TaskDetailPanel';
|
||||
import { TaskDetailModal } from './components/task-detail/TaskDetailModal';
|
||||
import { TaskCreationWizard } from './components/TaskCreationWizard';
|
||||
import { AppSettingsDialog, type AppSection } from './components/settings/AppSettings';
|
||||
import type { ProjectSettingsSection } from './components/settings/ProjectSettingsContent';
|
||||
@@ -29,7 +29,6 @@ import { Insights } from './components/Insights';
|
||||
import { GitHubIssues } from './components/GitHubIssues';
|
||||
import { Changelog } from './components/Changelog';
|
||||
import { Worktrees } from './components/Worktrees';
|
||||
import { AgentProfiles } from './components/AgentProfiles';
|
||||
import { WelcomeScreen } from './components/WelcomeScreen';
|
||||
import { RateLimitModal } from './components/RateLimitModal';
|
||||
import { SDKRateLimitModal } from './components/SDKRateLimitModal';
|
||||
@@ -43,7 +42,8 @@ import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings } from './stores/settings-store';
|
||||
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
|
||||
import { useIpcListeners } from './hooks/useIpc';
|
||||
import type { Task, Project } from '../shared/types';
|
||||
import { COLOR_THEMES } from '../shared/constants';
|
||||
import type { Task, Project, ColorTheme } from '../shared/types';
|
||||
|
||||
export function App() {
|
||||
// Load IPC listeners for real-time updates
|
||||
@@ -69,6 +69,8 @@ export function App() {
|
||||
const [showInitDialog, setShowInitDialog] = useState(false);
|
||||
const [pendingProject, setPendingProject] = useState<Project | null>(null);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [initSuccess, setInitSuccess] = useState(false);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [skippedInitProjectId, setSkippedInitProjectId] = useState<string | null>(null);
|
||||
|
||||
// GitHub setup state (shown after Auto Claude init)
|
||||
@@ -141,6 +143,8 @@ export function App() {
|
||||
if (selectedProject && !selectedProject.autoBuildPath && skippedInitProjectId !== selectedProject.id) {
|
||||
// Project exists but isn't initialized - show init dialog
|
||||
setPendingProject(selectedProject);
|
||||
setInitError(null); // Clear any previous errors
|
||||
setInitSuccess(false); // Reset success flag
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}, [selectedProject, skippedInitProjectId, isInitializing]);
|
||||
@@ -173,21 +177,38 @@ export function App() {
|
||||
|
||||
// Apply theme on load
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const applyTheme = () => {
|
||||
// Apply light/dark mode
|
||||
if (settings.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
root.classList.add('dark');
|
||||
} else if (settings.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
// System preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Apply color theme via data-theme attribute
|
||||
// Validate colorTheme against known themes, fallback to 'default' if invalid
|
||||
const validThemeIds = COLOR_THEMES.map((t) => t.id);
|
||||
const rawColorTheme = settings.colorTheme ?? 'default';
|
||||
const colorTheme: ColorTheme = validThemeIds.includes(rawColorTheme as ColorTheme)
|
||||
? (rawColorTheme as ColorTheme)
|
||||
: 'default';
|
||||
|
||||
if (colorTheme === 'default') {
|
||||
root.removeAttribute('data-theme');
|
||||
} else {
|
||||
root.setAttribute('data-theme', colorTheme);
|
||||
}
|
||||
|
||||
applyTheme();
|
||||
|
||||
// Listen for system theme changes
|
||||
@@ -202,7 +223,7 @@ export function App() {
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, [settings.theme]);
|
||||
}, [settings.theme, settings.colorTheme]);
|
||||
|
||||
// Update selected task when tasks change (for real-time updates)
|
||||
useEffect(() => {
|
||||
@@ -232,6 +253,8 @@ export function App() {
|
||||
if (project && !project.autoBuildPath) {
|
||||
// Project doesn't have Auto Claude initialized, show init dialog
|
||||
setPendingProject(project);
|
||||
setInitError(null); // Clear any previous errors
|
||||
setInitSuccess(false); // Reset success flag
|
||||
setShowInitDialog(true);
|
||||
}
|
||||
}
|
||||
@@ -244,24 +267,45 @@ export function App() {
|
||||
if (!pendingProject) return;
|
||||
|
||||
const projectId = pendingProject.id;
|
||||
console.log('[InitDialog] Starting initialization for project:', projectId);
|
||||
setIsInitializing(true);
|
||||
setInitSuccess(false);
|
||||
setInitError(null); // Clear any previous errors
|
||||
try {
|
||||
const result = await initializeProject(projectId);
|
||||
console.log('[InitDialog] Initialization result:', result);
|
||||
|
||||
if (result?.success) {
|
||||
console.log('[InitDialog] Initialization successful, closing dialog');
|
||||
// Get the updated project from store
|
||||
const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId);
|
||||
console.log('[InitDialog] Updated project:', updatedProject);
|
||||
|
||||
// Clear init dialog state
|
||||
setPendingProject(null);
|
||||
// Mark as successful to prevent onOpenChange from treating this as a skip
|
||||
setInitSuccess(true);
|
||||
setIsInitializing(false);
|
||||
|
||||
// Now close the dialog
|
||||
setShowInitDialog(false);
|
||||
setPendingProject(null);
|
||||
|
||||
// Show GitHub setup modal
|
||||
if (updatedProject) {
|
||||
setGitHubSetupProject(updatedProject);
|
||||
setShowGitHubSetup(true);
|
||||
}
|
||||
} else {
|
||||
// Initialization failed - show error but keep dialog open
|
||||
console.log('[InitDialog] Initialization failed, showing error');
|
||||
const errorMessage = result?.error || 'Failed to initialize Auto Claude. Please try again.';
|
||||
setInitError(errorMessage);
|
||||
setIsInitializing(false);
|
||||
}
|
||||
} finally {
|
||||
} catch (error) {
|
||||
// Unexpected error occurred
|
||||
console.error('[InitDialog] Unexpected error during initialization:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
|
||||
setInitError(errorMessage);
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
@@ -274,10 +318,16 @@ export function App() {
|
||||
if (!gitHubSetupProject) return;
|
||||
|
||||
try {
|
||||
// NOTE: settings.githubToken is a GitHub access token (from gh CLI),
|
||||
// NOT a Claude Code OAuth token. They are different things:
|
||||
// - GitHub token: for GitHub API access (repo operations)
|
||||
// - Claude token: for Claude AI access (run.py, roadmap, etc.)
|
||||
// The user needs to separately authenticate with Claude using 'claude setup-token'
|
||||
|
||||
// Update project env config with GitHub settings
|
||||
await window.electronAPI.updateProjectEnv(gitHubSetupProject.id, {
|
||||
githubEnabled: true,
|
||||
githubToken: settings.githubToken,
|
||||
githubToken: settings.githubToken, // GitHub token for repo access
|
||||
githubRepo: settings.githubRepo
|
||||
});
|
||||
|
||||
@@ -302,11 +352,14 @@ export function App() {
|
||||
};
|
||||
|
||||
const handleSkipInit = () => {
|
||||
console.log('[InitDialog] User skipped initialization');
|
||||
if (pendingProject) {
|
||||
setSkippedInitProjectId(pendingProject.id);
|
||||
}
|
||||
setShowInitDialog(false);
|
||||
setPendingProject(null);
|
||||
setInitError(null); // Clear any error when skipping
|
||||
setInitSuccess(false); // Reset success flag
|
||||
};
|
||||
|
||||
const handleGoToTask = (taskId: string) => {
|
||||
@@ -408,9 +461,6 @@ export function App() {
|
||||
{activeView === 'worktrees' && selectedProjectId && (
|
||||
<Worktrees projectId={selectedProjectId} />
|
||||
)}
|
||||
{activeView === 'agent-profiles' && (
|
||||
<AgentProfiles />
|
||||
)}
|
||||
{activeView === 'agent-tools' && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -433,10 +483,12 @@ export function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Task detail panel */}
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel task={selectedTask} onClose={handleCloseTaskDetail} />
|
||||
)}
|
||||
{/* Task detail modal */}
|
||||
<TaskDetailModal
|
||||
open={!!selectedTask}
|
||||
task={selectedTask}
|
||||
onOpenChange={(open) => !open && handleCloseTaskDetail()}
|
||||
/>
|
||||
|
||||
{/* Dialogs */}
|
||||
{selectedProjectId && (
|
||||
@@ -471,9 +523,10 @@ export function App() {
|
||||
|
||||
{/* Initialize Auto Claude Dialog */}
|
||||
<Dialog open={showInitDialog} onOpenChange={(open) => {
|
||||
console.log('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess });
|
||||
// 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) {
|
||||
// Don't trigger if: successful init, no pending project, or currently initializing
|
||||
if (!open && pendingProject && !isInitializing && !initSuccess) {
|
||||
handleSkipInit();
|
||||
}
|
||||
}}>
|
||||
@@ -509,6 +562,19 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{initError && (
|
||||
<div className="mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Initialization Failed</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{initError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleSkipInit} disabled={isInitializing}>
|
||||
|
||||
@@ -24,7 +24,7 @@ function createTestFeature(overrides: Partial<RoadmapFeature> = {}): RoadmapFeat
|
||||
impact: 'medium',
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: ['Test criteria'],
|
||||
userStories: ['As a user, I want to test'],
|
||||
...overrides
|
||||
@@ -309,7 +309,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: ['Criteria 1'],
|
||||
userStories: ['User story 1']
|
||||
};
|
||||
@@ -336,7 +336,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'medium' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
};
|
||||
@@ -393,7 +393,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -414,7 +414,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'high' as const,
|
||||
phaseId: 'phase-1',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -438,7 +438,7 @@ describe('Roadmap Store', () => {
|
||||
impact: 'medium' as const,
|
||||
phaseId: 'phase-3',
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
});
|
||||
@@ -489,7 +489,7 @@ describe('Roadmap Store', () => {
|
||||
|
||||
describe('updateFeatureStatus', () => {
|
||||
it('should update feature status by id', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'under_review' })];
|
||||
const roadmap = createTestRoadmap({ features });
|
||||
|
||||
useRoadmapStore.setState({ roadmap });
|
||||
@@ -502,8 +502,8 @@ describe('Roadmap Store', () => {
|
||||
});
|
||||
|
||||
describe('updateFeatureLinkedSpec', () => {
|
||||
it('should update linked spec and set status to planned', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'idea' })];
|
||||
it('should update linked spec and set status to in_progress', () => {
|
||||
const features = [createTestFeature({ id: 'feature-1', status: 'under_review' })];
|
||||
const roadmap = createTestRoadmap({ features });
|
||||
|
||||
useRoadmapStore.setState({ roadmap });
|
||||
@@ -512,7 +512,7 @@ describe('Roadmap Store', () => {
|
||||
|
||||
const state = useRoadmapStore.getState();
|
||||
expect(state.roadmap?.features[0].linkedSpecId).toBe('spec-abc');
|
||||
expect(state.roadmap?.features[0].status).toBe('planned');
|
||||
expect(state.roadmap?.features[0].status).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -596,9 +596,9 @@ describe('Roadmap Store', () => {
|
||||
it('should return correct stats', () => {
|
||||
const roadmap = createTestRoadmap({
|
||||
features: [
|
||||
createTestFeature({ priority: 'must', status: 'idea', complexity: 'high' }),
|
||||
createTestFeature({ priority: 'must', status: 'under_review', complexity: 'high' }),
|
||||
createTestFeature({ priority: 'must', status: 'planned', complexity: 'medium' }),
|
||||
createTestFeature({ priority: 'should', status: 'idea', complexity: 'low' })
|
||||
createTestFeature({ priority: 'should', status: 'under_review', complexity: 'low' })
|
||||
]
|
||||
});
|
||||
|
||||
@@ -607,7 +607,7 @@ describe('Roadmap Store', () => {
|
||||
expect(stats.total).toBe(3);
|
||||
expect(stats.byPriority['must']).toBe(2);
|
||||
expect(stats.byPriority['should']).toBe(1);
|
||||
expect(stats.byStatus['idea']).toBe(2);
|
||||
expect(stats.byStatus['under_review']).toBe(2);
|
||||
expect(stats.byStatus['planned']).toBe(1);
|
||||
expect(stats.byComplexity['high']).toBe(1);
|
||||
expect(stats.byComplexity['medium']).toBe(1);
|
||||
|
||||
@@ -48,7 +48,8 @@ import {
|
||||
import type {
|
||||
RoadmapPhase,
|
||||
RoadmapFeaturePriority,
|
||||
RoadmapFeatureStatus
|
||||
RoadmapFeatureStatus,
|
||||
FeatureSource
|
||||
} from '../../shared/types';
|
||||
|
||||
/**
|
||||
@@ -147,9 +148,10 @@ export function AddFeatureDialog({
|
||||
impact,
|
||||
phaseId,
|
||||
dependencies: [],
|
||||
status: 'idea' as RoadmapFeatureStatus,
|
||||
status: 'under_review' as RoadmapFeatureStatus,
|
||||
acceptanceCriteria: [],
|
||||
userStories: []
|
||||
userStories: [],
|
||||
source: { provider: 'internal' }
|
||||
});
|
||||
|
||||
// Persist to file via IPC
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Used in TaskCreationWizard and TaskEditDialog.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Brain, Scale, Zap, Sliders, Sparkles, ChevronDown, ChevronUp, Pencil } from 'lucide-react';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -220,28 +220,37 @@ export function AgentProfileSelector({
|
||||
|
||||
{/* 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'
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Clickable Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhaseDetails(!showPhaseDetails)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between p-4 text-left',
|
||||
'hover:bg-muted/50 transition-colors',
|
||||
!disabled && 'cursor-pointer'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-foreground">Phase Configuration</span>
|
||||
{!showPhaseDetails && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Click to customize</span>
|
||||
</span>
|
||||
)}
|
||||
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>
|
||||
</div>
|
||||
{showPhaseDetails ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Compact summary when collapsed */}
|
||||
{!showPhaseDetails && (
|
||||
{/* Compact summary when collapsed */}
|
||||
{!showPhaseDetails && (
|
||||
<div className="px-4 pb-4 -mt-1">
|
||||
<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];
|
||||
@@ -253,55 +262,61 @@ export function AgentProfileSelector({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed Phase Configuration */}
|
||||
{showPhaseDetails && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-border pt-4">
|
||||
{(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">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
{PHASE_LABELS[phase].label}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[10px] 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 className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Model</Label>
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Thinking</Label>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -19,7 +19,7 @@ const iconMap: Record<string, React.ElementType> = {
|
||||
*/
|
||||
export function AgentProfiles() {
|
||||
const settings = useSettingsStore((state) => state.settings);
|
||||
const selectedProfileId = settings.selectedAgentProfile || 'balanced';
|
||||
const selectedProfileId = settings.selectedAgentProfile || 'auto';
|
||||
|
||||
const handleSelectProfile = async (profileId: string) => {
|
||||
await saveSettings({ selectedAgentProfile: profileId });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,9 +24,10 @@ interface CustomModelModalProps {
|
||||
currentConfig?: InsightsModelConfig;
|
||||
onSave: (config: InsightsModelConfig) => void;
|
||||
onClose: () => void;
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export function CustomModelModal({ currentConfig, onSave, onClose }: CustomModelModalProps) {
|
||||
export function CustomModelModal({ currentConfig, onSave, onClose, open = true }: CustomModelModalProps) {
|
||||
const [model, setModel] = useState<ModelType>(
|
||||
currentConfig?.model || 'sonnet'
|
||||
);
|
||||
@@ -34,6 +35,14 @@ export function CustomModelModal({ currentConfig, onSave, onClose }: CustomModel
|
||||
currentConfig?.thinkingLevel || 'medium'
|
||||
);
|
||||
|
||||
// Sync internal state when modal opens or config changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setModel(currentConfig?.model || 'sonnet');
|
||||
setThinkingLevel(currentConfig?.thinkingLevel || 'medium');
|
||||
}
|
||||
}, [open, currentConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
profileId: 'custom',
|
||||
@@ -43,7 +52,7 @@ export function CustomModelModal({ currentConfig, onSave, onClose }: CustomModel
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Custom Model Configuration</DialogTitle>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Globe, RefreshCw, TrendingUp, CheckCircle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface ExistingCompetitorAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUseExisting: () => void;
|
||||
onRunNew: () => void;
|
||||
onSkip: () => void;
|
||||
analysisDate?: Date;
|
||||
}
|
||||
|
||||
export function ExistingCompetitorAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUseExisting,
|
||||
onRunNew,
|
||||
onSkip,
|
||||
analysisDate,
|
||||
}: ExistingCompetitorAnalysisDialogProps) {
|
||||
const handleUseExisting = () => {
|
||||
onUseExisting();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleRunNew = () => {
|
||||
onRunNew();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return 'recently';
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Competitor Analysis Options
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-muted-foreground">
|
||||
This project has an existing competitor analysis from {formatDate(analysisDate)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="py-4 space-y-3">
|
||||
{/* Option 1: Use existing (recommended) */}
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
className="w-full rounded-lg bg-primary/10 border border-primary/30 p-4 text-left hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Use existing analysis
|
||||
<span className="text-xs text-primary font-normal">(Recommended)</span>
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Reuse the competitor insights you already have. Faster and no additional web searches.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 2: Run new analysis */}
|
||||
<button
|
||||
onClick={handleRunNew}
|
||||
className="w-full rounded-lg bg-muted/50 border border-border p-4 text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
Run new analysis
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Perform fresh web searches to get updated competitor information. Takes longer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Option 3: Skip */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full rounded-lg bg-muted/30 border border-border/50 p-4 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground/60 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Skip competitor analysis
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">
|
||||
Generate roadmap without any competitor insights.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="sm:justify-start">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useState, useRef, useEffect, type DragEvent } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder, File, FileCode, FileJson, FileText, FileImage, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { FileNode } from '../../shared/types';
|
||||
@@ -70,15 +70,19 @@ export function FileTreeItem({
|
||||
isLoading,
|
||||
onToggle,
|
||||
}: FileTreeItemProps) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: node.path,
|
||||
data: {
|
||||
type: 'file',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
}
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragImageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Cleanup drag image on unmount to prevent memory leaks
|
||||
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -94,15 +98,62 @@ export function FileTreeItem({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
|
||||
// Set the drag data as JSON
|
||||
const dragData = {
|
||||
type: 'file-reference',
|
||||
path: node.path,
|
||||
name: node.name,
|
||||
isDirectory: node.isDirectory
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
||||
e.dataTransfer.setData('text/plain', `@${node.name}`);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
// Create a custom drag image using safe DOM manipulation (no innerHTML)
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'flex items-center gap-2 bg-card border border-primary rounded-md px-3 py-2 shadow-lg text-sm';
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.textContent = node.isDirectory ? '📁' : '📄';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = node.name;
|
||||
|
||||
dragImage.appendChild(iconSpan);
|
||||
dragImage.appendChild(nameSpan);
|
||||
dragImage.style.position = 'absolute';
|
||||
dragImage.style.top = '-1000px';
|
||||
dragImage.style.left = '-1000px';
|
||||
document.body.appendChild(dragImage);
|
||||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
|
||||
// Store reference for cleanup in dragend
|
||||
dragImageRef.current = dragImage;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
|
||||
// Clean up drag image element
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={cn(
|
||||
'flex items-center gap-1 py-1 px-2 rounded cursor-grab select-none',
|
||||
'hover:bg-accent/50 transition-colors',
|
||||
isDragging && 'opacity-50 bg-accent'
|
||||
isDragging && 'opacity-50 bg-accent ring-2 ring-primary'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Github,
|
||||
GitBranch,
|
||||
Key,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
SelectValue
|
||||
} from './ui/select';
|
||||
import { GitHubOAuthFlow } from './project-settings/GitHubOAuthFlow';
|
||||
import { ClaudeOAuthFlow } from './project-settings/ClaudeOAuthFlow';
|
||||
import type { Project, ProjectSettings } from '../../shared/types';
|
||||
|
||||
interface GitHubSetupModalProps {
|
||||
@@ -36,15 +38,16 @@ interface GitHubSetupModalProps {
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
type SetupStep = 'auth' | 'repo' | 'branch' | 'complete';
|
||||
type SetupStep = 'github-auth' | 'claude-auth' | 'repo' | 'branch' | 'complete';
|
||||
|
||||
/**
|
||||
* GitHub Setup Modal - Required setup flow after Auto Claude initialization
|
||||
* 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)
|
||||
* 1. Authenticate with GitHub (via gh CLI OAuth) - for repo operations
|
||||
* 2. Authenticate with Claude (via claude CLI OAuth) - for AI features
|
||||
* 3. Detect/confirm repository
|
||||
* 4. Select base branch for tasks (with recommended default)
|
||||
*/
|
||||
export function GitHubSetupModal({
|
||||
open,
|
||||
@@ -53,7 +56,7 @@ export function GitHubSetupModal({
|
||||
onComplete,
|
||||
onSkip
|
||||
}: GitHubSetupModalProps) {
|
||||
const [step, setStep] = useState<SetupStep>('auth');
|
||||
const [step, setStep] = useState<SetupStep>('github-auth');
|
||||
const [githubToken, setGithubToken] = useState<string | null>(null);
|
||||
const [githubRepo, setGithubRepo] = useState<string | null>(null);
|
||||
const [detectedRepo, setDetectedRepo] = useState<string | null>(null);
|
||||
@@ -67,7 +70,7 @@ export function GitHubSetupModal({
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('auth');
|
||||
setStep('github-auth');
|
||||
setGithubToken(null);
|
||||
setGithubRepo(null);
|
||||
setDetectedRepo(null);
|
||||
@@ -140,9 +143,16 @@ export function GitHubSetupModal({
|
||||
return branchList[0] || null;
|
||||
};
|
||||
|
||||
// Handle OAuth success
|
||||
const handleAuthSuccess = async (token: string) => {
|
||||
// Handle GitHub OAuth success
|
||||
const handleGitHubAuthSuccess = async (token: string) => {
|
||||
setGithubToken(token);
|
||||
// Move to Claude auth step
|
||||
setStep('claude-auth');
|
||||
};
|
||||
|
||||
// Handle Claude OAuth success
|
||||
const handleClaudeAuthSuccess = async () => {
|
||||
// Claude token is already saved to active profile by the OAuth flow
|
||||
// Move to repo detection
|
||||
await detectRepository();
|
||||
};
|
||||
@@ -161,7 +171,7 @@ export function GitHubSetupModal({
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 'auth':
|
||||
case 'github-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -176,7 +186,29 @@ export function GitHubSetupModal({
|
||||
|
||||
<div className="py-4">
|
||||
<GitHubOAuthFlow
|
||||
onSuccess={handleAuthSuccess}
|
||||
onSuccess={handleGitHubAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'claude-auth':
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Connect to Claude AI
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auto Claude uses Claude AI for intelligent features like Roadmap generation, Task automation, and Ideation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<ClaudeOAuthFlow
|
||||
onSuccess={handleClaudeAuthSuccess}
|
||||
onCancel={onSkip}
|
||||
/>
|
||||
</div>
|
||||
@@ -372,20 +404,27 @@ export function GitHubSetupModal({
|
||||
|
||||
// Progress indicator
|
||||
const renderProgress = () => {
|
||||
const steps: { key: SetupStep; label: string }[] = [
|
||||
{ key: 'auth', label: 'Connect' },
|
||||
{ key: 'branch', label: 'Configure' },
|
||||
const steps: { label: string }[] = [
|
||||
{ label: 'Authenticate' },
|
||||
{ label: 'Configure' },
|
||||
];
|
||||
|
||||
// Don't show progress on complete step
|
||||
if (step === 'complete') return null;
|
||||
|
||||
const currentIndex = step === 'auth' ? 0 : step === 'repo' ? 0 : 1;
|
||||
// Map steps to progress indices
|
||||
// Auth steps (github-auth, claude-auth, repo) = 0
|
||||
// Config steps (branch) = 1
|
||||
const currentIndex =
|
||||
step === 'github-auth' ? 0 :
|
||||
step === 'claude-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 key={index} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
index < currentIndex
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Sliders, Check } from 'lucide-react';
|
||||
import { Brain, Scale, Zap, Sparkles, Sliders, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -22,7 +22,8 @@ interface InsightsModelSelectorProps {
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
Brain,
|
||||
Scale,
|
||||
Zap
|
||||
Zap,
|
||||
Sparkles
|
||||
};
|
||||
|
||||
export function InsightsModelSelector({
|
||||
@@ -32,8 +33,9 @@ export function InsightsModelSelector({
|
||||
}: InsightsModelSelectorProps) {
|
||||
const [showCustomModal, setShowCustomModal] = useState(false);
|
||||
|
||||
// Default to 'balanced' if no config
|
||||
const selectedProfileId = currentConfig?.profileId || 'balanced';
|
||||
// Default to 'balanced' if no config, or if 'auto' profile was selected (not applicable for insights)
|
||||
const rawProfileId = currentConfig?.profileId || 'balanced';
|
||||
const selectedProfileId = rawProfileId === 'auto' ? 'balanced' : rawProfileId;
|
||||
const profile = DEFAULT_AGENT_PROFILES.find(p => p.id === selectedProfileId);
|
||||
|
||||
// Get the appropriate icon
|
||||
@@ -90,7 +92,7 @@ export function InsightsModelSelector({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Agent Profile</DropdownMenuLabel>
|
||||
{DEFAULT_AGENT_PROFILES.map((p) => {
|
||||
{DEFAULT_AGENT_PROFILES.filter(p => !p.isAutoProfile).map((p) => {
|
||||
const ProfileIcon = iconMap[p.icon || 'Brain'];
|
||||
const isSelected = selectedProfileId === p.id;
|
||||
const modelLabel = AVAILABLE_MODELS.find(m => m.value === p.model)?.label;
|
||||
@@ -132,13 +134,12 @@ export function InsightsModelSelector({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{showCustomModal && (
|
||||
<CustomModelModal
|
||||
currentConfig={currentConfig}
|
||||
onSave={handleCustomSave}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
/>
|
||||
)}
|
||||
<CustomModelModal
|
||||
open={showCustomModal}
|
||||
currentConfig={currentConfig}
|
||||
onSave={handleCustomSave}
|
||||
onClose={() => setShowCustomModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ export function ProjectSettings({ project, open, onOpenChange }: ProjectSettings
|
||||
onUpdateConfig={updateEnvConfig}
|
||||
gitHubConnectionStatus={gitHubConnectionStatus}
|
||||
isCheckingGitHub={isCheckingGitHub}
|
||||
projectName={project.name}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { RoadmapGenerationProgress } from './RoadmapGenerationProgress';
|
||||
import { CompetitorAnalysisDialog } from './CompetitorAnalysisDialog';
|
||||
import { ExistingCompetitorAnalysisDialog } from './ExistingCompetitorAnalysisDialog';
|
||||
import { CompetitorAnalysisViewer } from './CompetitorAnalysisViewer';
|
||||
import { AddFeatureDialog } from './AddFeatureDialog';
|
||||
import { RoadmapHeader } from './roadmap/RoadmapHeader';
|
||||
import { RoadmapEmptyState } from './roadmap/RoadmapEmptyState';
|
||||
import { RoadmapTabs } from './roadmap/RoadmapTabs';
|
||||
import { FeatureDetailPanel } from './roadmap/FeatureDetailPanel';
|
||||
import { useRoadmapData, useFeatureActions, useRoadmapGeneration } from './roadmap/hooks';
|
||||
import { useRoadmapData, useFeatureActions, useRoadmapGeneration, useRoadmapSave, useFeatureDelete } from './roadmap/hooks';
|
||||
import { getCompetitorInsightsForFeature } from './roadmap/utils';
|
||||
import type { RoadmapFeature } from '../../shared/types';
|
||||
import type { RoadmapProps } from './roadmap/types';
|
||||
@@ -15,14 +16,24 @@ import type { RoadmapProps } from './roadmap/types';
|
||||
export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
// State management
|
||||
const [selectedFeature, setSelectedFeature] = useState<RoadmapFeature | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('phases');
|
||||
const [activeTab, setActiveTab] = useState('kanban');
|
||||
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||
const [showCompetitorViewer, setShowCompetitorViewer] = useState(false);
|
||||
|
||||
// Custom hooks
|
||||
const { roadmap, competitorAnalysis, generationStatus } = useRoadmapData(projectId);
|
||||
const { convertFeatureToSpec } = useFeatureActions();
|
||||
const { saveRoadmap } = useRoadmapSave(projectId);
|
||||
const { deleteFeature } = useFeatureDelete(projectId);
|
||||
const {
|
||||
competitorAnalysisDate,
|
||||
// New dialog for existing analysis
|
||||
showExistingAnalysisDialog,
|
||||
setShowExistingAnalysisDialog,
|
||||
handleUseExistingAnalysis,
|
||||
handleRunNewAnalysis,
|
||||
handleSkipAnalysis,
|
||||
// Original dialog for no existing analysis
|
||||
showCompetitorDialog,
|
||||
setShowCompetitorDialog,
|
||||
handleGenerate,
|
||||
@@ -61,12 +72,22 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
return (
|
||||
<>
|
||||
<RoadmapEmptyState onGenerate={handleGenerate} />
|
||||
{/* Dialog for projects WITHOUT existing competitor analysis */}
|
||||
<CompetitorAnalysisDialog
|
||||
open={showCompetitorDialog}
|
||||
onOpenChange={setShowCompetitorDialog}
|
||||
onAccept={handleCompetitorDialogAccept}
|
||||
onDecline={handleCompetitorDialogDecline}
|
||||
/>
|
||||
{/* Dialog for projects WITH existing competitor analysis */}
|
||||
<ExistingCompetitorAnalysisDialog
|
||||
open={showExistingAnalysisDialog}
|
||||
onOpenChange={setShowExistingAnalysisDialog}
|
||||
onUseExisting={handleUseExistingAnalysis}
|
||||
onRunNew={handleRunNewAnalysis}
|
||||
onSkip={handleSkipAnalysis}
|
||||
analysisDate={competitorAnalysisDate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -92,6 +113,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onFeatureSelect={setSelectedFeature}
|
||||
onConvertToSpec={handleConvertToSpec}
|
||||
onGoToTask={handleGoToTask}
|
||||
onSave={saveRoadmap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,11 +124,12 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onClose={() => setSelectedFeature(null)}
|
||||
onConvertToSpec={handleConvertToSpec}
|
||||
onGoToTask={handleGoToTask}
|
||||
onDelete={deleteFeature}
|
||||
competitorInsights={getCompetitorInsightsForFeature(selectedFeature, competitorAnalysis)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Competitor Analysis Permission Dialog */}
|
||||
{/* Competitor Analysis Permission Dialog (no existing analysis) */}
|
||||
<CompetitorAnalysisDialog
|
||||
open={showCompetitorDialog}
|
||||
onOpenChange={setShowCompetitorDialog}
|
||||
@@ -114,6 +137,16 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
onDecline={handleCompetitorDialogDecline}
|
||||
/>
|
||||
|
||||
{/* Competitor Analysis Options Dialog (existing analysis) */}
|
||||
<ExistingCompetitorAnalysisDialog
|
||||
open={showExistingAnalysisDialog}
|
||||
onOpenChange={setShowExistingAnalysisDialog}
|
||||
onUseExisting={handleUseExistingAnalysis}
|
||||
onRunNew={handleRunNewAnalysis}
|
||||
onSkip={handleSkipAnalysis}
|
||||
analysisDate={competitorAnalysisDate}
|
||||
/>
|
||||
|
||||
{/* Competitor Analysis Viewer */}
|
||||
<CompetitorAnalysisViewer
|
||||
analysis={competitorAnalysis}
|
||||
|
||||
@@ -18,17 +18,18 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
arrayMove
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Plus, Inbox } from 'lucide-react';
|
||||
import { Plus, Inbox, Eye, Calendar, Play, Check } from 'lucide-react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Card } from './ui/card';
|
||||
import { SortableFeatureCard } from './SortableFeatureCard';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useRoadmapStore } from '../stores/roadmap-store';
|
||||
import {
|
||||
useRoadmapStore,
|
||||
getFeaturesByPhase
|
||||
} from '../stores/roadmap-store';
|
||||
import type { RoadmapFeature, RoadmapPhase, Roadmap } from '../../shared/types';
|
||||
ROADMAP_STATUS_COLUMNS,
|
||||
type RoadmapStatusColumn
|
||||
} from '../../shared/constants';
|
||||
import type { RoadmapFeature, RoadmapFeatureStatus, Roadmap } from '../../shared/types';
|
||||
|
||||
interface RoadmapKanbanViewProps {
|
||||
roadmap: Roadmap;
|
||||
@@ -38,37 +39,43 @@ interface RoadmapKanbanViewProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
interface DroppablePhaseColumnProps {
|
||||
phase: RoadmapPhase;
|
||||
interface DroppableStatusColumnProps {
|
||||
column: RoadmapStatusColumn;
|
||||
features: RoadmapFeature[];
|
||||
roadmap: Roadmap;
|
||||
onFeatureClick: (feature: RoadmapFeature) => void;
|
||||
onConvertToSpec?: (feature: RoadmapFeature) => void;
|
||||
onGoToTask?: (specId: string) => void;
|
||||
isOver: boolean;
|
||||
}
|
||||
|
||||
// Get phase status color for column header
|
||||
function getPhaseStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'border-t-success';
|
||||
case 'in_progress':
|
||||
return 'border-t-primary';
|
||||
// Get icon component for status
|
||||
function getStatusIcon(iconName: string) {
|
||||
switch (iconName) {
|
||||
case 'Eye':
|
||||
return <Eye className="h-3.5 w-3.5" />;
|
||||
case 'Calendar':
|
||||
return <Calendar className="h-3.5 w-3.5" />;
|
||||
case 'Play':
|
||||
return <Play className="h-3.5 w-3.5" />;
|
||||
case 'Check':
|
||||
return <Check className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return 'border-t-muted-foreground/30';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DroppablePhaseColumn({
|
||||
phase,
|
||||
function DroppableStatusColumn({
|
||||
column,
|
||||
features,
|
||||
roadmap,
|
||||
onFeatureClick,
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
isOver
|
||||
}: DroppablePhaseColumnProps) {
|
||||
}: DroppableStatusColumnProps) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: phase.id
|
||||
id: column.id
|
||||
});
|
||||
|
||||
const featureIds = features.map((f) => f.id);
|
||||
@@ -78,7 +85,7 @@ function DroppablePhaseColumn({
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex min-w-80 w-80 shrink-0 flex-col rounded-xl border border-white/5 bg-linear-to-b from-secondary/30 to-transparent backdrop-blur-sm transition-all duration-200',
|
||||
getPhaseStatusColor(phase.status),
|
||||
column.color,
|
||||
'border-t-2',
|
||||
isOver && 'drop-zone-highlight'
|
||||
)}
|
||||
@@ -87,29 +94,26 @@ function DroppablePhaseColumn({
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
phase.status === 'completed'
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center',
|
||||
column.id === 'done'
|
||||
? 'bg-success/10 text-success'
|
||||
: phase.status === 'in_progress'
|
||||
: column.id === 'in_progress'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: column.id === 'planned'
|
||||
? 'bg-info/10 text-info'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{phase.order}
|
||||
{getStatusIcon(column.icon)}
|
||||
</div>
|
||||
<h2 className="font-semibold text-sm text-foreground truncate max-w-[180px]">
|
||||
{phase.name}
|
||||
<h2 className="font-semibold text-sm text-foreground">
|
||||
{column.label}
|
||||
</h2>
|
||||
<span className="column-count-badge">
|
||||
{features.length}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={phase.status === 'completed' ? 'default' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{phase.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
@@ -151,6 +155,7 @@ function DroppablePhaseColumn({
|
||||
<SortableFeatureCard
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
roadmap={roadmap}
|
||||
onClick={() => onFeatureClick(feature)}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
@@ -175,8 +180,7 @@ export function RoadmapKanbanView({
|
||||
const [activeFeature, setActiveFeature] = useState<RoadmapFeature | null>(null);
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
|
||||
const reorderFeatures = useRoadmapStore((state) => state.reorderFeatures);
|
||||
const updateFeaturePhase = useRoadmapStore((state) => state.updateFeaturePhase);
|
||||
const updateFeatureStatus = useRoadmapStore((state) => state.updateFeatureStatus);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -189,17 +193,17 @@ export function RoadmapKanbanView({
|
||||
})
|
||||
);
|
||||
|
||||
// Get features grouped by phase
|
||||
const featuresByPhase = useMemo(() => {
|
||||
// Get features grouped by status
|
||||
const featuresByStatus = useMemo(() => {
|
||||
const grouped: Record<string, RoadmapFeature[]> = {};
|
||||
roadmap.phases.forEach((phase) => {
|
||||
grouped[phase.id] = getFeaturesByPhase(roadmap, phase.id);
|
||||
ROADMAP_STATUS_COLUMNS.forEach((column) => {
|
||||
grouped[column.id] = roadmap.features.filter((f) => f.status === column.id);
|
||||
});
|
||||
return grouped;
|
||||
}, [roadmap]);
|
||||
}, [roadmap.features]);
|
||||
|
||||
// Get all phase IDs for detecting column drops
|
||||
const phaseIds = useMemo(() => roadmap.phases.map((p) => p.id), [roadmap.phases]);
|
||||
// Get all status IDs for detecting column drops
|
||||
const statusIds = useMemo(() => ROADMAP_STATUS_COLUMNS.map((c) => c.id), []);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
@@ -219,16 +223,16 @@ export function RoadmapKanbanView({
|
||||
|
||||
const overId = over.id as string;
|
||||
|
||||
// Check if over a phase column
|
||||
if (phaseIds.includes(overId)) {
|
||||
// Check if over a status column
|
||||
if (statusIds.includes(overId)) {
|
||||
setOverColumnId(overId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if over a feature - get its phase
|
||||
// Check if over a feature - get its status
|
||||
const overFeature = roadmap.features.find((f) => f.id === overId);
|
||||
if (overFeature) {
|
||||
setOverColumnId(overFeature.phaseId);
|
||||
setOverColumnId(overFeature.status);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,59 +249,36 @@ export function RoadmapKanbanView({
|
||||
|
||||
if (!draggedFeature) return;
|
||||
|
||||
// Determine target phase
|
||||
let targetPhaseId: string;
|
||||
let targetFeatureIndex: number = -1;
|
||||
// Determine target status
|
||||
let targetStatus: RoadmapFeatureStatus;
|
||||
|
||||
if (phaseIds.includes(overId)) {
|
||||
// Dropped directly on a phase column
|
||||
targetPhaseId = overId;
|
||||
if (statusIds.includes(overId)) {
|
||||
// Dropped directly on a status column
|
||||
targetStatus = overId as RoadmapFeatureStatus;
|
||||
} else {
|
||||
// Dropped on a feature - get its phase and position
|
||||
// Dropped on a feature - get its status
|
||||
const overFeature = roadmap.features.find((f) => f.id === overId);
|
||||
if (!overFeature) return;
|
||||
targetPhaseId = overFeature.phaseId;
|
||||
const targetFeatures = featuresByPhase[targetPhaseId] || [];
|
||||
targetFeatureIndex = targetFeatures.findIndex((f) => f.id === overId);
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
|
||||
const sourcePhaseId = draggedFeature.phaseId;
|
||||
const sourceStatus = draggedFeature.status;
|
||||
|
||||
if (sourcePhaseId !== targetPhaseId) {
|
||||
// Moving to a different phase
|
||||
updateFeaturePhase(activeFeatureId, targetPhaseId);
|
||||
|
||||
// If dropped on a specific feature, reorder within the new phase
|
||||
if (targetFeatureIndex !== -1) {
|
||||
const targetFeatures = [...(featuresByPhase[targetPhaseId] || [])];
|
||||
// Add the moved feature at the target position
|
||||
const updatedIds = targetFeatures.map((f) => f.id);
|
||||
if (!updatedIds.includes(activeFeatureId)) {
|
||||
updatedIds.splice(targetFeatureIndex, 0, activeFeatureId);
|
||||
reorderFeatures(targetPhaseId, updatedIds);
|
||||
}
|
||||
}
|
||||
if (sourceStatus !== targetStatus) {
|
||||
// Moving to a different status
|
||||
updateFeatureStatus(activeFeatureId, targetStatus);
|
||||
|
||||
// Trigger save callback
|
||||
onSave?.();
|
||||
} else {
|
||||
// Reordering within the same phase
|
||||
const sourceFeatures = featuresByPhase[sourcePhaseId] || [];
|
||||
const oldIndex = sourceFeatures.findIndex((f) => f.id === activeFeatureId);
|
||||
const newIndex = targetFeatureIndex !== -1 ? targetFeatureIndex : sourceFeatures.length - 1;
|
||||
|
||||
if (oldIndex !== newIndex) {
|
||||
const reorderedIds = arrayMove(
|
||||
sourceFeatures.map((f) => f.id),
|
||||
oldIndex,
|
||||
newIndex
|
||||
);
|
||||
reorderFeatures(sourcePhaseId, reorderedIds);
|
||||
|
||||
// Trigger save callback
|
||||
onSave?.();
|
||||
}
|
||||
}
|
||||
// Note: We don't support reordering within status columns for now
|
||||
// Features are displayed in their natural order within each status
|
||||
};
|
||||
|
||||
// Get status label for a feature (for display in drag overlay)
|
||||
const getStatusLabelForFeature = (feature: RoadmapFeature) => {
|
||||
const statusColumn = ROADMAP_STATUS_COLUMNS.find((c) => c.id === feature.status);
|
||||
return statusColumn?.label || 'Unknown Status';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -311,19 +292,18 @@ export function RoadmapKanbanView({
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 gap-4 overflow-x-auto p-6">
|
||||
{roadmap.phases
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((phase) => (
|
||||
<DroppablePhaseColumn
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
features={featuresByPhase[phase.id] || []}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
isOver={overColumnId === phase.id}
|
||||
/>
|
||||
))}
|
||||
{ROADMAP_STATUS_COLUMNS.map((column) => (
|
||||
<DroppableStatusColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
features={featuresByStatus[column.id] || []}
|
||||
roadmap={roadmap}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
isOver={overColumnId === column.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay - enhanced visual feedback */}
|
||||
@@ -331,6 +311,11 @@ export function RoadmapKanbanView({
|
||||
{activeFeature ? (
|
||||
<div className="drag-overlay-card">
|
||||
<Card className="p-4 w-80 shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{getStatusLabelForFeature(activeFeature)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-medium">{activeFeature.title}</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{activeFeature.description}
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
Moon,
|
||||
Sun,
|
||||
LayoutGrid,
|
||||
Terminal,
|
||||
Map,
|
||||
@@ -18,8 +16,7 @@ import {
|
||||
FileText,
|
||||
Sparkles,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
UserCog
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -53,13 +50,13 @@ import {
|
||||
checkProjectVersion,
|
||||
updateProjectAutoBuild
|
||||
} from '../stores/project-store';
|
||||
import { useSettingsStore, saveSettings } from '../stores/settings-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { AddProjectModal } from './AddProjectModal';
|
||||
import { GitSetupModal } from './GitSetupModal';
|
||||
import { RateLimitIndicator } from './RateLimitIndicator';
|
||||
import type { Project, AutoBuildVersionInfo, GitStatus } from '../../shared/types';
|
||||
|
||||
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools' | 'agent-profiles';
|
||||
export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools';
|
||||
|
||||
interface SidebarProps {
|
||||
onSettingsClick: () => void;
|
||||
@@ -87,8 +84,7 @@ const projectNavItems: NavItem[] = [
|
||||
|
||||
const toolsNavItems: NavItem[] = [
|
||||
{ id: 'github-issues', label: 'GitHub Issues', icon: Github, shortcut: 'G' },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch, shortcut: 'W' },
|
||||
{ id: 'agent-profiles', label: 'Agent Profiles', icon: UserCog, shortcut: 'P' }
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch, shortcut: 'W' }
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -267,21 +263,6 @@ export function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = settings.theme === 'dark' ? 'light' : 'dark';
|
||||
saveSettings({ theme: newTheme });
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
const isDark =
|
||||
settings.theme === 'dark' ||
|
||||
(settings.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
const handleNavClick = (view: SidebarView) => {
|
||||
onViewChange?.(view);
|
||||
};
|
||||
@@ -317,18 +298,8 @@ export function Sidebar({
|
||||
<TooltipProvider>
|
||||
<div className="flex h-full w-64 flex-col bg-sidebar border-r border-border">
|
||||
{/* Header with drag area - extra top padding for macOS traffic lights */}
|
||||
<div className="electron-drag flex h-14 items-center justify-between px-4 pt-6">
|
||||
<div className="electron-drag flex h-14 items-center px-4 pt-6">
|
||||
<span className="electron-no-drag text-lg font-bold text-primary">Auto Claude</span>
|
||||
<div className="electron-no-drag flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme}>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle theme</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-2" />
|
||||
|
||||
@@ -9,17 +9,18 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from './ui/tooltip';
|
||||
import { Play, ExternalLink, TrendingUp } from 'lucide-react';
|
||||
import { Play, ExternalLink, TrendingUp, Layers, ThumbsUp } from 'lucide-react';
|
||||
import {
|
||||
ROADMAP_PRIORITY_COLORS,
|
||||
ROADMAP_PRIORITY_LABELS,
|
||||
ROADMAP_COMPLEXITY_COLORS,
|
||||
ROADMAP_IMPACT_COLORS
|
||||
} from '../../shared/constants';
|
||||
import type { RoadmapFeature } from '../../shared/types';
|
||||
import type { RoadmapFeature, Roadmap } from '../../shared/types';
|
||||
|
||||
interface SortableFeatureCardProps {
|
||||
feature: RoadmapFeature;
|
||||
roadmap?: Roadmap;
|
||||
onClick: () => void;
|
||||
onConvertToSpec?: (feature: RoadmapFeature) => void;
|
||||
onGoToTask?: (specId: string) => void;
|
||||
@@ -27,6 +28,7 @@ interface SortableFeatureCardProps {
|
||||
|
||||
export function SortableFeatureCard({
|
||||
feature,
|
||||
roadmap,
|
||||
onClick,
|
||||
onConvertToSpec,
|
||||
onGoToTask
|
||||
@@ -51,6 +53,12 @@ export function SortableFeatureCard({
|
||||
const hasCompetitorInsight =
|
||||
!!feature.competitorInsightIds && feature.competitorInsightIds.length > 0;
|
||||
|
||||
// Get phase name for the feature
|
||||
const phaseName = roadmap?.phases.find((p) => p.id === feature.phaseId)?.name;
|
||||
|
||||
// Check if feature has external source (e.g., Canny)
|
||||
const isExternal = feature.source?.provider && feature.source.provider !== 'internal';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -70,13 +78,29 @@ export function SortableFeatureCard({
|
||||
{/* Header - Title with priority badge and action button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] px-1.5 py-0', ROADMAP_PRIORITY_COLORS[feature.priority])}
|
||||
>
|
||||
{ROADMAP_PRIORITY_LABELS[feature.priority]}
|
||||
</Badge>
|
||||
{phaseName && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-muted-foreground border-muted-foreground/30"
|
||||
>
|
||||
<Layers className="h-2.5 w-2.5 mr-0.5" />
|
||||
{phaseName.length > 12 ? `${phaseName.slice(0, 12)}...` : phaseName}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Phase: {phaseName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasCompetitorInsight && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -135,7 +159,7 @@ export function SortableFeatureCard({
|
||||
</p>
|
||||
|
||||
{/* Metadata badges - compact row */}
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] px-1.5 py-0', ROADMAP_COMPLEXITY_COLORS[feature.complexity])}
|
||||
@@ -148,6 +172,39 @@ export function SortableFeatureCard({
|
||||
>
|
||||
{feature.impact}
|
||||
</Badge>
|
||||
{/* Show vote count if from external source */}
|
||||
{feature.votes !== undefined && feature.votes > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-muted-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-2.5 w-2.5 mr-0.5" />
|
||||
{feature.votes}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{feature.votes} votes from user feedback
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Show external source indicator */}
|
||||
{isExternal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-orange-500 border-orange-500/30"
|
||||
>
|
||||
{feature.source?.provider === 'canny' ? 'Canny' : 'External'}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Imported from {feature.source?.provider}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,6 @@ interface TaskCardProps {
|
||||
export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
const [isStuck, setIsStuck] = useState(false);
|
||||
const [isRecovering, setIsRecovering] = useState(false);
|
||||
const [hasCheckedRunning, setHasCheckedRunning] = useState(false);
|
||||
|
||||
const isRunning = task.status === 'in_progress';
|
||||
const executionPhase = task.executionProgress?.phase;
|
||||
@@ -53,25 +52,46 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
// Check if task is stuck (status says in_progress but no actual process)
|
||||
// Add a grace period to avoid false positives during process spawn
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
if (isRunning && !hasCheckedRunning) {
|
||||
// Wait 2 seconds before checking - gives process time to spawn and register
|
||||
timeoutId = setTimeout(() => {
|
||||
checkTaskRunning(task.id).then((actuallyRunning) => {
|
||||
setIsStuck(!actuallyRunning);
|
||||
setHasCheckedRunning(true);
|
||||
});
|
||||
}, 2000);
|
||||
} else if (!isRunning) {
|
||||
if (!isRunning) {
|
||||
setIsStuck(false);
|
||||
setHasCheckedRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial check after 2s grace period
|
||||
const initialTimeout = setTimeout(() => {
|
||||
checkTaskRunning(task.id).then((actuallyRunning) => {
|
||||
setIsStuck(!actuallyRunning);
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
// Periodic re-check every 15 seconds
|
||||
const recheckInterval = setInterval(() => {
|
||||
checkTaskRunning(task.id).then((actuallyRunning) => {
|
||||
setIsStuck(!actuallyRunning);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
clearTimeout(initialTimeout);
|
||||
clearInterval(recheckInterval);
|
||||
};
|
||||
}, [task.id, isRunning, hasCheckedRunning]);
|
||||
}, [task.id, isRunning]);
|
||||
|
||||
// Add visibility change handler to re-validate on focus
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && isRunning) {
|
||||
checkTaskRunning(task.id).then((actuallyRunning) => {
|
||||
setIsStuck(!actuallyRunning);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [task.id, isRunning]);
|
||||
|
||||
const handleStartStop = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -89,8 +109,6 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
const result = await recoverStuckTask(task.id, { autoRestart: true });
|
||||
if (result.success) {
|
||||
setIsStuck(false);
|
||||
// Reset the check flag so it will re-verify running state
|
||||
setHasCheckedRunning(false);
|
||||
}
|
||||
setIsRecovering(false);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, type ClipboardEvent, type DragEvent } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core';
|
||||
import { Loader2, ChevronDown, ChevronUp, Image as ImageIcon, X, RotateCcw, File, Folder, FolderTree, FileDown } from 'lucide-react';
|
||||
import { Loader2, ChevronDown, ChevronUp, Image as ImageIcon, X, RotateCcw, FolderTree, GitBranch } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -84,6 +74,15 @@ export function TaskCreationWizard({
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showImages, setShowImages] = useState(false);
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(false);
|
||||
const [showGitOptions, setShowGitOptions] = useState(false);
|
||||
|
||||
// Git options state
|
||||
// Use a special value to represent "use project default" since Radix UI Select doesn't allow empty string values
|
||||
const PROJECT_DEFAULT_BRANCH = '__project_default__';
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
|
||||
// Get project path from project store
|
||||
const projects = useProjectStore((state) => state.projects);
|
||||
@@ -103,11 +102,12 @@ export function TaskCreationWizard({
|
||||
const [model, setModel] = useState<ModelType | ''>(selectedProfile.model);
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | ''>(selectedProfile.thinkingLevel);
|
||||
// Auto profile - per-phase configuration
|
||||
// Use custom settings from app settings if available, otherwise fall back to defaults
|
||||
const [phaseModels, setPhaseModels] = useState<PhaseModelConfig | undefined>(
|
||||
selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS
|
||||
);
|
||||
const [phaseThinking, setPhaseThinking] = useState<PhaseThinkingConfig | undefined>(
|
||||
selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING
|
||||
);
|
||||
|
||||
// Image attachments
|
||||
@@ -123,43 +123,12 @@ export function TaskCreationWizard({
|
||||
const [isDraftRestored, setIsDraftRestored] = useState(false);
|
||||
const [pasteSuccess, setPasteSuccess] = useState(false);
|
||||
|
||||
// Drag-and-drop state for file references
|
||||
const [activeDragData, setActiveDragData] = useState<{
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Ref for the textarea to handle paste events
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Drag-and-drop state for images over textarea
|
||||
const [isDragOverTextarea, setIsDragOverTextarea] = useState(false);
|
||||
|
||||
// Setup drag sensors with distance constraint to prevent accidental drags
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 8px movement required before drag starts
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
// Load draft when dialog opens, or initialize from selected profile
|
||||
useEffect(() => {
|
||||
if (open && projectId) {
|
||||
@@ -175,8 +144,8 @@ export function TaskCreationWizard({
|
||||
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);
|
||||
setPhaseModels(draft.phaseModels || settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(draft.phaseThinking || settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages(draft.images);
|
||||
setReferencedFiles(draft.referencedFiles ?? []);
|
||||
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
|
||||
@@ -191,15 +160,60 @@ export function TaskCreationWizard({
|
||||
}
|
||||
// Note: Referenced Files section is always visible, no need to expand
|
||||
} else {
|
||||
// No draft - initialize from selected profile
|
||||
// No draft - initialize from selected profile and custom settings
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
}
|
||||
}
|
||||
}, [open, projectId, settings.selectedAgentProfile, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
}, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel]);
|
||||
|
||||
// Fetch branches and project default branch when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && projectPath) {
|
||||
fetchBranches();
|
||||
fetchProjectDefaultBranch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, projectPath]);
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) return;
|
||||
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch branches:', err);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectDefaultBranch = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
// Get env config to check if there's a configured default branch
|
||||
const result = await window.electronAPI.getProjectEnv(projectId);
|
||||
if (result.success && result.data?.defaultBranch) {
|
||||
setProjectDefaultBranch(result.data.defaultBranch);
|
||||
} else if (projectPath) {
|
||||
// Fall back to auto-detect
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
setProjectDefaultBranch(detectResult.data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project default branch:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current form state as a draft
|
||||
@@ -321,7 +335,7 @@ export function TaskCreationWizard({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drop on textarea for image files
|
||||
* Handle drop on textarea for file references and images
|
||||
*/
|
||||
const handleTextareaDrop = useCallback(
|
||||
async (e: DragEvent<HTMLTextAreaElement>) => {
|
||||
@@ -331,6 +345,40 @@ export function TaskCreationWizard({
|
||||
|
||||
if (isCreating) return;
|
||||
|
||||
// First, check for file reference drops (from the file explorer)
|
||||
const jsonData = e.dataTransfer?.getData('application/json');
|
||||
if (jsonData) {
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
if (data.type === 'file-reference' && data.name) {
|
||||
// Insert @mention at cursor position in the textarea
|
||||
const textarea = descriptionRef.current;
|
||||
if (textarea) {
|
||||
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; // Don't process as image
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, continue to image handling
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to image file handling
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
@@ -398,102 +446,9 @@ export function TaskCreationWizard({
|
||||
setTimeout(() => setPasteSuccess(false), 2000);
|
||||
}
|
||||
},
|
||||
[images, isCreating]
|
||||
[images, isCreating, description]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle drag start - capture file data for overlay
|
||||
*/
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as {
|
||||
type: string;
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
} | undefined;
|
||||
|
||||
if (data?.type === 'file') {
|
||||
setActiveDragData({
|
||||
path: data.path,
|
||||
name: data.name,
|
||||
isDirectory: data.isDirectory
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle drag end - insert @mention in description or add to referencedFiles
|
||||
*/
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Clear drag state
|
||||
setActiveDragData(null);
|
||||
|
||||
// If not dropped on a valid target, do nothing
|
||||
if (!over) return;
|
||||
|
||||
const data = active.data.current as {
|
||||
type?: string;
|
||||
path?: string;
|
||||
name?: string;
|
||||
isDirectory?: boolean;
|
||||
} | undefined;
|
||||
|
||||
// Only process file drops
|
||||
if (data?.type !== 'file' || !data.path || !data.name) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
/**
|
||||
* Parse @mentions from description and create ReferencedFile entries
|
||||
* Merges with existing referencedFiles, avoiding duplicates
|
||||
@@ -560,6 +515,8 @@ export function TaskCreationWizard({
|
||||
if (images.length > 0) metadata.attachedImages = images;
|
||||
if (allReferencedFiles.length > 0) metadata.referencedFiles = allReferencedFiles;
|
||||
if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true;
|
||||
// Only include baseBranch if it's not the project default placeholder
|
||||
if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch;
|
||||
|
||||
// Title is optional - if empty, it will be auto-generated by the backend
|
||||
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
|
||||
@@ -586,19 +543,21 @@ export function TaskCreationWizard({
|
||||
setPriority('');
|
||||
setComplexity('');
|
||||
setImpact('');
|
||||
// Reset to selected profile defaults
|
||||
// Reset to selected profile defaults and custom settings
|
||||
setProfileId(settings.selectedAgentProfile || 'auto');
|
||||
setModel(selectedProfile.model);
|
||||
setThinkingLevel(selectedProfile.thinkingLevel);
|
||||
setPhaseModels(selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setPhaseModels(settings.customPhaseModels || selectedProfile.phaseModels || DEFAULT_PHASE_MODELS);
|
||||
setPhaseThinking(settings.customPhaseThinking || selectedProfile.phaseThinking || DEFAULT_PHASE_THINKING);
|
||||
setImages([]);
|
||||
setReferencedFiles([]);
|
||||
setRequireReviewBeforeCoding(false);
|
||||
setBaseBranch(PROJECT_DEFAULT_BRANCH);
|
||||
setError(null);
|
||||
setShowAdvanced(false);
|
||||
setShowImages(false);
|
||||
setShowFileExplorer(false);
|
||||
setShowGitOptions(false);
|
||||
setIsDraftRestored(false);
|
||||
setPasteSuccess(false);
|
||||
};
|
||||
@@ -633,53 +592,17 @@ export function TaskCreationWizard({
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90vh] p-0 overflow-hidden transition-all duration-300 ease-out",
|
||||
showFileExplorer ? "sm:max-w-[900px]" : "sm:max-w-[550px]"
|
||||
)}
|
||||
hideCloseButton={showFileExplorer}
|
||||
>
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* 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>
|
||||
)}
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-[90vh] p-0 overflow-hidden transition-all duration-300 ease-out",
|
||||
showFileExplorer ? "sm:max-w-[900px]" : "sm:max-w-[550px]"
|
||||
)}
|
||||
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 relative">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-foreground">Create New Task</DialogTitle>
|
||||
@@ -712,8 +635,8 @@ export function TaskCreationWizard({
|
||||
<Label htmlFor="description" className="text-sm font-medium text-foreground">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
{/* Wrap textarea in drop zone for file @mentions */}
|
||||
<div ref={setTextareaDropRef} className="relative">
|
||||
{/* Wrap textarea for file @mentions */}
|
||||
<div className="relative">
|
||||
{/* Syntax highlight overlay for @mentions */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none overflow-hidden rounded-md border border-transparent"
|
||||
@@ -756,20 +679,11 @@ export function TaskCreationWizard({
|
||||
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"
|
||||
// Visual feedback when dragging over textarea
|
||||
isDragOverTextarea && !isCreating && "border-primary bg-primary/5 ring-2 ring-primary/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: Drag files from the explorer to insert @references, or paste screenshots with {navigator.platform.includes('Mac') ? '⌘V' : 'Ctrl+V'}.
|
||||
@@ -1036,6 +950,65 @@ export function TaskCreationWizard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Options Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGitOptions(!showGitOptions)}
|
||||
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">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Git Options (optional)
|
||||
{baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
{baseBranch}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{showGitOptions ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Git Options */}
|
||||
{showGitOptions && (
|
||||
<div className="space-y-4 p-4 rounded-lg border border-border bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base-branch" className="text-sm font-medium text-foreground">
|
||||
Base Branch (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={baseBranch}
|
||||
onValueChange={setBaseBranch}
|
||||
disabled={isCreating || isLoadingBranches}
|
||||
>
|
||||
<SelectTrigger id="base-branch" className="h-9">
|
||||
<SelectValue placeholder={`Use project default${projectDefaultBranch ? ` (${projectDefaultBranch})` : ''}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROJECT_DEFAULT_BRANCH}>
|
||||
Use project default{projectDefaultBranch ? ` (${projectDefaultBranch})` : ''}
|
||||
</SelectItem>
|
||||
{branches.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override the branch this task's worktree will be created from. Leave empty to use the project's configured default branch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
||||
@@ -1078,33 +1051,18 @@ export function TaskCreationWizard({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
{/* File Explorer Drawer */}
|
||||
{projectPath && (
|
||||
<TaskFileExplorerDrawer
|
||||
isOpen={showFileExplorer}
|
||||
onClose={() => setShowFileExplorer(false)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Drag overlay - shows what's being dragged */}
|
||||
<DragOverlay>
|
||||
{activeDragData && (
|
||||
<div className="flex items-center gap-2 bg-card border border-border rounded-md px-3 py-2 shadow-lg">
|
||||
{activeDragData.isDirectory ? (
|
||||
<Folder className="h-4 w-4 text-warning" />
|
||||
) : (
|
||||
<File className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{activeDragData.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
{/* File Explorer Drawer */}
|
||||
{projectPath && (
|
||||
<TaskFileExplorerDrawer
|
||||
isOpen={showFileExplorer}
|
||||
onClose={() => setShowFileExplorer(false)}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function IdeaDetailPanel({ idea, onClose, onConvert, onGoToTask, onDismis
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-96 bg-card border-l border-border shadow-lg flex flex-col z-50">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 p-4 border-b border-border">
|
||||
<div className="shrink-0 p-4 border-b border-border electron-no-drag">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -19,11 +19,14 @@ import { Label } from '../ui/label';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import { Switch } from '../ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from '../ui/tooltip';
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../ui/select';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { GraphitiLLMProvider, GraphitiEmbeddingProvider, AppSettings } from '../../../shared/types';
|
||||
|
||||
interface GraphitiStepProps {
|
||||
onNext: () => void;
|
||||
@@ -31,30 +34,97 @@ interface GraphitiStepProps {
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
// Provider configurations with descriptions
|
||||
const LLM_PROVIDERS: Array<{
|
||||
id: GraphitiLLMProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
requiresApiKey: boolean;
|
||||
}> = [
|
||||
{ id: 'openai', name: 'OpenAI', description: 'GPT models (recommended)', requiresApiKey: true },
|
||||
{ id: 'anthropic', name: 'Anthropic', description: 'Claude models', requiresApiKey: true },
|
||||
{ id: 'google', name: 'Google AI', description: 'Gemini models', requiresApiKey: true },
|
||||
{ id: 'groq', name: 'Groq', description: 'Llama models (fast inference)', requiresApiKey: true },
|
||||
{ id: 'azure_openai', name: 'Azure OpenAI', description: 'Enterprise Azure deployment', requiresApiKey: true },
|
||||
{ id: 'ollama', name: 'Ollama', description: 'Local models (free)', requiresApiKey: false }
|
||||
];
|
||||
|
||||
const EMBEDDING_PROVIDERS: Array<{
|
||||
id: GraphitiEmbeddingProvider;
|
||||
name: string;
|
||||
description: string;
|
||||
requiresApiKey: boolean;
|
||||
}> = [
|
||||
{ id: 'openai', name: 'OpenAI', description: 'text-embedding-3-small (recommended)', requiresApiKey: true },
|
||||
{ id: 'voyage', name: 'Voyage AI', description: 'voyage-3 (great with Anthropic)', requiresApiKey: true },
|
||||
{ id: 'google', name: 'Google AI', description: 'Gemini text-embedding-004', requiresApiKey: true },
|
||||
{ id: 'huggingface', name: 'HuggingFace', description: 'Open source models', requiresApiKey: true },
|
||||
{ id: 'azure_openai', name: 'Azure OpenAI', description: 'Enterprise Azure embeddings', requiresApiKey: true },
|
||||
{ id: 'ollama', name: 'Ollama', description: 'Local embeddings (free)', requiresApiKey: false }
|
||||
];
|
||||
|
||||
interface GraphitiConfig {
|
||||
enabled: boolean;
|
||||
falkorDbUri: string;
|
||||
openAiApiKey: string;
|
||||
llmProvider: GraphitiLLMProvider;
|
||||
embeddingProvider: GraphitiEmbeddingProvider;
|
||||
// OpenAI
|
||||
openaiApiKey: string;
|
||||
// Anthropic
|
||||
anthropicApiKey: string;
|
||||
// Azure OpenAI
|
||||
azureOpenaiApiKey: string;
|
||||
azureOpenaiBaseUrl: string;
|
||||
azureOpenaiLlmDeployment: string;
|
||||
azureOpenaiEmbeddingDeployment: string;
|
||||
// Voyage
|
||||
voyageApiKey: string;
|
||||
// Google
|
||||
googleApiKey: string;
|
||||
// Groq
|
||||
groqApiKey: string;
|
||||
// HuggingFace
|
||||
huggingfaceApiKey: string;
|
||||
// Ollama
|
||||
ollamaBaseUrl: string;
|
||||
ollamaLlmModel: string;
|
||||
ollamaEmbeddingModel: string;
|
||||
ollamaEmbeddingDim: string;
|
||||
}
|
||||
|
||||
interface ValidationStatus {
|
||||
falkordb: { tested: boolean; success: boolean; message: string } | null;
|
||||
openai: { tested: boolean; success: boolean; message: string } | null;
|
||||
provider: { tested: boolean; success: boolean; message: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphiti/FalkorDB configuration step for the onboarding wizard.
|
||||
* Allows users to optionally configure Graphiti memory backend.
|
||||
* Allows users to optionally configure Graphiti memory backend with multiple provider options.
|
||||
* This step is entirely optional and can be skipped.
|
||||
*/
|
||||
export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
const { settings, updateSettings } = useSettingsStore();
|
||||
const [config, setConfig] = useState<GraphitiConfig>({
|
||||
enabled: false,
|
||||
falkorDbUri: 'bolt://localhost:6379', // Standard FalkorDB port, will be auto-detected from Docker
|
||||
openAiApiKey: settings.globalOpenAIApiKey || ''
|
||||
falkorDbUri: 'bolt://localhost:6380',
|
||||
llmProvider: 'openai',
|
||||
embeddingProvider: 'openai',
|
||||
openaiApiKey: settings.globalOpenAIApiKey || '',
|
||||
anthropicApiKey: settings.globalAnthropicApiKey || '',
|
||||
azureOpenaiApiKey: '',
|
||||
azureOpenaiBaseUrl: '',
|
||||
azureOpenaiLlmDeployment: '',
|
||||
azureOpenaiEmbeddingDeployment: '',
|
||||
voyageApiKey: '',
|
||||
googleApiKey: settings.globalGoogleApiKey || '',
|
||||
groqApiKey: settings.globalGroqApiKey || '',
|
||||
huggingfaceApiKey: '',
|
||||
ollamaBaseUrl: settings.ollamaBaseUrl || 'http://localhost:11434',
|
||||
ollamaLlmModel: '',
|
||||
ollamaEmbeddingModel: '',
|
||||
ollamaEmbeddingDim: '768'
|
||||
});
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
@@ -63,7 +133,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<ValidationStatus>({
|
||||
falkordb: null,
|
||||
openai: null
|
||||
provider: null
|
||||
});
|
||||
|
||||
// Check Docker/Infrastructure availability on mount
|
||||
@@ -71,11 +141,9 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
const checkInfrastructure = async () => {
|
||||
setIsCheckingDocker(true);
|
||||
try {
|
||||
// Check infrastructure status via the electronAPI
|
||||
const result = await window.electronAPI.getInfrastructureStatus();
|
||||
setDockerAvailable(result?.success && result?.data?.docker?.running ? true : false);
|
||||
|
||||
// If FalkorDB is running, auto-detect and set the correct port
|
||||
if (result?.success && result?.data?.falkordb?.containerRunning) {
|
||||
const detectedPort = result.data.falkordb.port;
|
||||
setConfig(prev => ({
|
||||
@@ -84,7 +152,6 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Infrastructure check may fail, assume unavailable
|
||||
setDockerAvailable(false);
|
||||
} finally {
|
||||
setIsCheckingDocker(false);
|
||||
@@ -98,24 +165,75 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
setConfig(prev => ({ ...prev, enabled: checked }));
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
// Reset validation status when toggling
|
||||
setValidationStatus({ falkordb: null, openai: null });
|
||||
setValidationStatus({ falkordb: null, provider: null });
|
||||
};
|
||||
|
||||
const toggleShowApiKey = (key: string) => {
|
||||
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
// Get the required API key for the current provider configuration
|
||||
const getRequiredApiKey = (): string | null => {
|
||||
const { llmProvider, embeddingProvider } = config;
|
||||
|
||||
// Check LLM provider
|
||||
if (llmProvider === 'openai' || embeddingProvider === 'openai') {
|
||||
if (!config.openaiApiKey.trim()) return 'OpenAI API key';
|
||||
}
|
||||
if (llmProvider === 'anthropic') {
|
||||
if (!config.anthropicApiKey.trim()) return 'Anthropic API key';
|
||||
}
|
||||
if (llmProvider === 'azure_openai' || embeddingProvider === 'azure_openai') {
|
||||
if (!config.azureOpenaiApiKey.trim()) return 'Azure OpenAI API key';
|
||||
if (!config.azureOpenaiBaseUrl.trim()) return 'Azure OpenAI Base URL';
|
||||
if (llmProvider === 'azure_openai' && !config.azureOpenaiLlmDeployment.trim()) {
|
||||
return 'Azure OpenAI LLM deployment name';
|
||||
}
|
||||
if (embeddingProvider === 'azure_openai' && !config.azureOpenaiEmbeddingDeployment.trim()) {
|
||||
return 'Azure OpenAI embedding deployment name';
|
||||
}
|
||||
}
|
||||
if (embeddingProvider === 'voyage') {
|
||||
if (!config.voyageApiKey.trim()) return 'Voyage API key';
|
||||
}
|
||||
if (llmProvider === 'google' || embeddingProvider === 'google') {
|
||||
if (!config.googleApiKey.trim()) return 'Google API key';
|
||||
}
|
||||
if (llmProvider === 'groq') {
|
||||
if (!config.groqApiKey.trim()) return 'Groq API key';
|
||||
}
|
||||
if (embeddingProvider === 'huggingface') {
|
||||
if (!config.huggingfaceApiKey.trim()) return 'HuggingFace API key';
|
||||
}
|
||||
if (llmProvider === 'ollama') {
|
||||
if (!config.ollamaLlmModel.trim()) return 'Ollama LLM model name';
|
||||
}
|
||||
if (embeddingProvider === 'ollama') {
|
||||
if (!config.ollamaEmbeddingModel.trim()) return 'Ollama embedding model name';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!config.openAiApiKey.trim()) {
|
||||
setError('Please enter an OpenAI API key to test the connection');
|
||||
const missingKey = getRequiredApiKey();
|
||||
if (missingKey) {
|
||||
setError(`Please enter ${missingKey} to test the connection`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setError(null);
|
||||
setValidationStatus({ falkordb: null, openai: null });
|
||||
setValidationStatus({ falkordb: null, provider: null });
|
||||
|
||||
try {
|
||||
// For now, use the existing OpenAI validation - this will be expanded
|
||||
const apiKey = config.llmProvider === 'openai' ? config.openaiApiKey :
|
||||
config.embeddingProvider === 'openai' ? config.openaiApiKey : '';
|
||||
|
||||
const result = await window.electronAPI.testGraphitiConnection(
|
||||
config.falkorDbUri,
|
||||
config.openAiApiKey.trim()
|
||||
apiKey.trim()
|
||||
);
|
||||
|
||||
if (result?.success && result?.data) {
|
||||
@@ -125,10 +243,12 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
success: result.data.falkordb.success,
|
||||
message: result.data.falkordb.message
|
||||
},
|
||||
openai: {
|
||||
provider: {
|
||||
tested: true,
|
||||
success: result.data.openai.success,
|
||||
message: result.data.openai.message
|
||||
message: result.data.openai.success
|
||||
? `${config.llmProvider} / ${config.embeddingProvider} providers configured`
|
||||
: result.data.openai.message
|
||||
}
|
||||
});
|
||||
|
||||
@@ -138,7 +258,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
errors.push(`FalkorDB: ${result.data.falkordb.message}`);
|
||||
}
|
||||
if (!result.data.openai.success) {
|
||||
errors.push(`OpenAI: ${result.data.openai.message}`);
|
||||
errors.push(`Provider: ${result.data.openai.message}`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
@@ -156,13 +276,13 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config.enabled) {
|
||||
// If not enabled, just continue
|
||||
onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.openAiApiKey.trim()) {
|
||||
setError('OpenAI API key is required for Graphiti embeddings');
|
||||
const missingKey = getRequiredApiKey();
|
||||
if (missingKey) {
|
||||
setError(`${missingKey} is required`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,15 +290,38 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Save OpenAI API key to global settings
|
||||
const result = await window.electronAPI.saveSettings({
|
||||
globalOpenAIApiKey: config.openAiApiKey.trim()
|
||||
});
|
||||
// Save the primary API keys to global settings based on providers
|
||||
const settingsToSave: Record<string, string> = {
|
||||
graphitiLlmProvider: config.llmProvider,
|
||||
};
|
||||
|
||||
if (config.openaiApiKey.trim()) {
|
||||
settingsToSave.globalOpenAIApiKey = config.openaiApiKey.trim();
|
||||
}
|
||||
if (config.anthropicApiKey.trim()) {
|
||||
settingsToSave.globalAnthropicApiKey = config.anthropicApiKey.trim();
|
||||
}
|
||||
if (config.googleApiKey.trim()) {
|
||||
settingsToSave.globalGoogleApiKey = config.googleApiKey.trim();
|
||||
}
|
||||
if (config.groqApiKey.trim()) {
|
||||
settingsToSave.globalGroqApiKey = config.groqApiKey.trim();
|
||||
}
|
||||
if (config.ollamaBaseUrl.trim()) {
|
||||
settingsToSave.ollamaBaseUrl = config.ollamaBaseUrl.trim();
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.saveSettings(settingsToSave);
|
||||
|
||||
if (result?.success) {
|
||||
// Update local settings store
|
||||
updateSettings({ globalOpenAIApiKey: config.openAiApiKey.trim() });
|
||||
// Proceed to next step immediately after successful save
|
||||
// Update local settings store with API key settings
|
||||
const storeUpdate: Partial<Pick<AppSettings, 'globalOpenAIApiKey' | 'globalAnthropicApiKey' | 'globalGoogleApiKey' | 'globalGroqApiKey' | 'ollamaBaseUrl'>> = {};
|
||||
if (config.openaiApiKey.trim()) storeUpdate.globalOpenAIApiKey = config.openaiApiKey.trim();
|
||||
if (config.anthropicApiKey.trim()) storeUpdate.globalAnthropicApiKey = config.anthropicApiKey.trim();
|
||||
if (config.googleApiKey.trim()) storeUpdate.globalGoogleApiKey = config.googleApiKey.trim();
|
||||
if (config.groqApiKey.trim()) storeUpdate.globalGroqApiKey = config.groqApiKey.trim();
|
||||
if (config.ollamaBaseUrl.trim()) storeUpdate.ollamaBaseUrl = config.ollamaBaseUrl.trim();
|
||||
updateSettings(storeUpdate);
|
||||
onNext();
|
||||
} else {
|
||||
setError(result?.error || 'Failed to save Graphiti configuration');
|
||||
@@ -207,6 +350,370 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Render provider-specific configuration fields
|
||||
const renderProviderFields = () => {
|
||||
const { llmProvider, embeddingProvider } = config;
|
||||
const needsOpenAI = llmProvider === 'openai' || embeddingProvider === 'openai';
|
||||
const needsAnthropic = llmProvider === 'anthropic';
|
||||
const needsAzure = llmProvider === 'azure_openai' || embeddingProvider === 'azure_openai';
|
||||
const needsVoyage = embeddingProvider === 'voyage';
|
||||
const needsGoogle = llmProvider === 'google' || embeddingProvider === 'google';
|
||||
const needsGroq = llmProvider === 'groq';
|
||||
const needsHuggingFace = embeddingProvider === 'huggingface';
|
||||
const needsOllama = llmProvider === 'ollama' || embeddingProvider === 'ollama';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* OpenAI API Key */}
|
||||
{needsOpenAI && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="openai-key" className="text-sm font-medium text-foreground">
|
||||
OpenAI API Key
|
||||
</Label>
|
||||
{validationStatus.provider?.tested && needsOpenAI && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{validationStatus.provider.success ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="openai-key"
|
||||
type={showApiKey['openai'] ? 'text' : 'password'}
|
||||
value={config.openaiApiKey}
|
||||
onChange={(e) => {
|
||||
setConfig(prev => ({ ...prev, openaiApiKey: e.target.value }));
|
||||
setValidationStatus(prev => ({ ...prev, provider: null }));
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('openai')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['openai'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
OpenAI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anthropic API Key */}
|
||||
{needsAnthropic && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anthropic-key" className="text-sm font-medium text-foreground">
|
||||
Anthropic API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="anthropic-key"
|
||||
type={showApiKey['anthropic'] ? 'text' : 'password'}
|
||||
value={config.anthropicApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, anthropicApiKey: e.target.value }))}
|
||||
placeholder="sk-ant-..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('anthropic')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['anthropic'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
Anthropic Console
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Azure OpenAI Settings */}
|
||||
{needsAzure && (
|
||||
<div className="space-y-3 p-3 rounded-md bg-muted/50">
|
||||
<p className="text-sm font-medium text-foreground">Azure OpenAI Settings</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="azure-key" className="text-xs text-muted-foreground">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="azure-key"
|
||||
type={showApiKey['azure'] ? 'text' : 'password'}
|
||||
value={config.azureOpenaiApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, azureOpenaiApiKey: e.target.value }))}
|
||||
placeholder="Azure API key"
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('azure')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['azure'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="azure-url" className="text-xs text-muted-foreground">Base URL</Label>
|
||||
<Input
|
||||
id="azure-url"
|
||||
type="text"
|
||||
value={config.azureOpenaiBaseUrl}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, azureOpenaiBaseUrl: e.target.value }))}
|
||||
placeholder="https://your-resource.openai.azure.com"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
{llmProvider === 'azure_openai' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="azure-llm-deployment" className="text-xs text-muted-foreground">LLM Deployment Name</Label>
|
||||
<Input
|
||||
id="azure-llm-deployment"
|
||||
type="text"
|
||||
value={config.azureOpenaiLlmDeployment}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, azureOpenaiLlmDeployment: e.target.value }))}
|
||||
placeholder="gpt-4"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{embeddingProvider === 'azure_openai' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="azure-embedding-deployment" className="text-xs text-muted-foreground">Embedding Deployment Name</Label>
|
||||
<Input
|
||||
id="azure-embedding-deployment"
|
||||
type="text"
|
||||
value={config.azureOpenaiEmbeddingDeployment}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, azureOpenaiEmbeddingDeployment: e.target.value }))}
|
||||
placeholder="text-embedding-ada-002"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voyage API Key */}
|
||||
{needsVoyage && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voyage-key" className="text-sm font-medium text-foreground">
|
||||
Voyage API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="voyage-key"
|
||||
type={showApiKey['voyage'] ? 'text' : 'password'}
|
||||
value={config.voyageApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, voyageApiKey: e.target.value }))}
|
||||
placeholder="pa-..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('voyage')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['voyage'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://dash.voyageai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
Voyage AI
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google API Key */}
|
||||
{needsGoogle && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="google-key" className="text-sm font-medium text-foreground">
|
||||
Google API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="google-key"
|
||||
type={showApiKey['google'] ? 'text' : 'password'}
|
||||
value={config.googleApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, googleApiKey: e.target.value }))}
|
||||
placeholder="AIza..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('google')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['google'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
Google AI Studio
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groq API Key */}
|
||||
{needsGroq && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="groq-key" className="text-sm font-medium text-foreground">
|
||||
Groq API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="groq-key"
|
||||
type={showApiKey['groq'] ? 'text' : 'password'}
|
||||
value={config.groqApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, groqApiKey: e.target.value }))}
|
||||
placeholder="gsk_..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('groq')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['groq'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://console.groq.com/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
Groq Console
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HuggingFace API Key */}
|
||||
{needsHuggingFace && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="huggingface-key" className="text-sm font-medium text-foreground">
|
||||
HuggingFace API Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="huggingface-key"
|
||||
type={showApiKey['huggingface'] ? 'text' : 'password'}
|
||||
value={config.huggingfaceApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, huggingfaceApiKey: e.target.value }))}
|
||||
placeholder="hf_..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShowApiKey('huggingface')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey['huggingface'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Get your key from{' '}
|
||||
<a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
HuggingFace
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ollama Settings */}
|
||||
{needsOllama && (
|
||||
<div className="space-y-3 p-3 rounded-md bg-muted/50">
|
||||
<p className="text-sm font-medium text-foreground">Ollama Settings (Local)</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ollama-url" className="text-xs text-muted-foreground">Base URL</Label>
|
||||
<Input
|
||||
id="ollama-url"
|
||||
type="text"
|
||||
value={config.ollamaBaseUrl}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, ollamaBaseUrl: e.target.value }))}
|
||||
placeholder="http://localhost:11434"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
{llmProvider === 'ollama' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ollama-llm" className="text-xs text-muted-foreground">LLM Model</Label>
|
||||
<Input
|
||||
id="ollama-llm"
|
||||
type="text"
|
||||
value={config.ollamaLlmModel}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, ollamaLlmModel: e.target.value }))}
|
||||
placeholder="llama3.2, deepseek-r1:7b, etc."
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{embeddingProvider === 'ollama' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ollama-embedding" className="text-xs text-muted-foreground">Embedding Model</Label>
|
||||
<Input
|
||||
id="ollama-embedding"
|
||||
type="text"
|
||||
value={config.ollamaEmbeddingModel}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, ollamaEmbeddingModel: e.target.value }))}
|
||||
placeholder="nomic-embed-text"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ollama-dim" className="text-xs text-muted-foreground">Embedding Dimension</Label>
|
||||
<Input
|
||||
id="ollama-dim"
|
||||
type="number"
|
||||
value={config.ollamaEmbeddingDim}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, ollamaEmbeddingDim: e.target.value }))}
|
||||
placeholder="768"
|
||||
className="font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ensure Ollama is running locally. See{' '}
|
||||
<a href="https://ollama.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80">
|
||||
ollama.ai
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
@@ -276,7 +783,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<p className="text-sm text-destructive whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -344,7 +851,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
Enable Graphiti Memory
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Requires FalkorDB (Docker) and OpenAI API key
|
||||
Requires FalkorDB (Docker) and an LLM/embedding provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,76 +906,76 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OpenAI API Key */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="openai-key" className="text-sm font-medium text-foreground">
|
||||
OpenAI API Key
|
||||
{/* Provider Selection */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* LLM Provider */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
LLM Provider
|
||||
</Label>
|
||||
{validationStatus.openai && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{validationStatus.openai.success ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span className={`text-xs ${validationStatus.openai.success ? 'text-success' : 'text-destructive'}`}>
|
||||
{validationStatus.openai.success ? 'Valid' : 'Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="openai-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={config.openAiApiKey}
|
||||
onChange={(e) => {
|
||||
setConfig(prev => ({ ...prev, openAiApiKey: e.target.value }));
|
||||
setValidationStatus(prev => ({ ...prev, openai: null }));
|
||||
<Select
|
||||
value={config.llmProvider}
|
||||
onValueChange={(value: GraphitiLLMProvider) => {
|
||||
setConfig(prev => ({ ...prev, llmProvider: value }));
|
||||
setValidationStatus(prev => ({ ...prev, provider: null }));
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving || isValidating}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Required for generating embeddings. Get your key from{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
OpenAI
|
||||
</a>
|
||||
</p>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map(p => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Embedding Provider */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
Embedding Provider
|
||||
</Label>
|
||||
<Select
|
||||
value={config.embeddingProvider}
|
||||
onValueChange={(value: GraphitiEmbeddingProvider) => {
|
||||
setConfig(prev => ({ ...prev, embeddingProvider: value }));
|
||||
setValidationStatus(prev => ({ ...prev, provider: null }));
|
||||
}}
|
||||
disabled={isSaving || isValidating}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EMBEDDING_PROVIDERS.map(p => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider-specific fields */}
|
||||
{renderProviderFields()}
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!config.openAiApiKey.trim() || isValidating || isSaving}
|
||||
disabled={!!getRequiredApiKey() || isValidating || isSaving}
|
||||
className="w-full"
|
||||
>
|
||||
{isValidating ? (
|
||||
@@ -483,11 +990,21 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{validationStatus.falkordb?.success && validationStatus.openai?.success && (
|
||||
{validationStatus.falkordb?.success && validationStatus.provider?.success && (
|
||||
<p className="text-xs text-success text-center mt-2">
|
||||
All connections validated successfully!
|
||||
</p>
|
||||
)}
|
||||
{config.llmProvider !== 'openai' && config.llmProvider !== 'ollama' && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
Note: API key validation currently only fully supports OpenAI. Your key will be saved and used at runtime.
|
||||
</p>
|
||||
)}
|
||||
{config.llmProvider === 'ollama' && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||
Note: Ollama connection will be tested by checking if the server is reachable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -515,7 +1032,7 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={isCheckingDocker || (config.enabled && !config.openAiApiKey.trim() && !success) || isSaving || isValidating}
|
||||
disabled={isCheckingDocker || (config.enabled && !!getRequiredApiKey() && !success) || isSaving || isValidating}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
|
||||
interface ClaudeOAuthFlowProps {
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude OAuth flow component for setup wizard
|
||||
* Guides users through authenticating with Claude using claude setup-token
|
||||
*/
|
||||
export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
|
||||
const [status, setStatus] = useState<'ready' | 'authenticating' | 'success' | 'error'>('ready');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState<string | undefined>();
|
||||
|
||||
// Track if we've already started auth to prevent double-execution
|
||||
const hasStartedRef = useRef(false);
|
||||
// Track the auto-advance timeout so we can cancel it on unmount/re-render
|
||||
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Listen for OAuth token detection
|
||||
useEffect(() => {
|
||||
// Clear any pending timeout from previous effect run
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
successTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const unsubscribe = window.electronAPI.onTerminalOAuthToken((info) => {
|
||||
console.warn('[ClaudeOAuth] Token event received:', {
|
||||
success: info.success,
|
||||
hasEmail: !!info.email,
|
||||
profileId: info.profileId
|
||||
});
|
||||
|
||||
if (info.success) {
|
||||
setEmail(info.email);
|
||||
setStatus('success');
|
||||
// Auto-advance after a short delay to show success message
|
||||
// Store the timeout ID so cleanup can cancel it if needed
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
successTimeoutRef.current = null; // Clear ref since timeout fired
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(info.message || 'Failed to save OAuth token');
|
||||
setStatus('error');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
// Clear timeout on cleanup to prevent calling onSuccess after unmount
|
||||
if (successTimeoutRef.current) {
|
||||
clearTimeout(successTimeoutRef.current);
|
||||
successTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onSuccess]);
|
||||
|
||||
const handleStartAuth = async () => {
|
||||
if (hasStartedRef.current) {
|
||||
console.warn('[ClaudeOAuth] Auth already started, ignoring duplicate call');
|
||||
return;
|
||||
}
|
||||
hasStartedRef.current = true;
|
||||
|
||||
console.warn('[ClaudeOAuth] Starting Claude authentication');
|
||||
setStatus('authenticating');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the active profile ID
|
||||
const profilesResult = await window.electronAPI.getClaudeProfiles();
|
||||
|
||||
if (!profilesResult.success || !profilesResult.data) {
|
||||
throw new Error('Failed to get Claude profiles');
|
||||
}
|
||||
|
||||
const activeProfileId = profilesResult.data.activeProfileId;
|
||||
console.warn('[ClaudeOAuth] Initializing profile:', activeProfileId);
|
||||
|
||||
// Initialize the profile - this opens a terminal and runs 'claude setup-token'
|
||||
const result = await window.electronAPI.initializeClaudeProfile(activeProfileId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to start authentication');
|
||||
}
|
||||
|
||||
console.warn('[ClaudeOAuth] Authentication started, waiting for token...');
|
||||
// Status will be updated by the event listener when token is detected
|
||||
} catch (err) {
|
||||
console.error('[ClaudeOAuth] Authentication failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
setStatus('error');
|
||||
hasStartedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
hasStartedRef.current = false;
|
||||
setStatus('ready');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Ready to authenticate */}
|
||||
{status === 'ready' && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Key className="h-6 w-6 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticate with Claude
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Auto Claude requires Claude AI authentication for AI-powered features like
|
||||
Roadmap generation, Task automation, and Ideation.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will open a browser window to authenticate with your Claude account.
|
||||
Your credentials are stored securely and are valid for 1 year.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={handleStartAuth} size="lg" className="gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Authenticate with Claude
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authenticating */}
|
||||
{status === 'authenticating' && (
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticating...
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A terminal window has opened. Please complete the authentication in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-background/50 p-3 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">What's happening:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>A terminal opened and ran <code className="px-1 bg-muted rounded">claude setup-token</code></li>
|
||||
<li>Your browser should open to authenticate with Claude</li>
|
||||
<li>Complete the OAuth flow in your browser</li>
|
||||
<li>The terminal will display your token (starts with sk-ant-oat01-...)</li>
|
||||
<li>Auto Claude will automatically detect and save it</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{status === 'success' && (
|
||||
<Card className="border border-success/30 bg-success/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="h-6 w-6 text-success shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-success">
|
||||
Successfully Authenticated!
|
||||
</h3>
|
||||
<p className="text-sm text-success/80 mt-1">
|
||||
{email ? `Connected as ${email}` : 'Your Claude credentials have been saved'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3 text-xs text-success/70">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>You can now use all Auto Claude AI features</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && error && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-destructive/30 bg-destructive/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-destructive">
|
||||
Authentication Failed
|
||||
</h3>
|
||||
<p className="text-sm text-destructive/80 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button onClick={onCancel} variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button for ready/authenticating states */}
|
||||
{(status === 'ready' || status === 'authenticating') && onCancel && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button onClick={onCancel} variant="ghost" size="sm">
|
||||
Skip for now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Github, RefreshCw, KeyRound } from 'lucide-react';
|
||||
import { Github, RefreshCw, KeyRound, Info } from 'lucide-react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
@@ -19,6 +19,7 @@ interface GitHubIntegrationSectionProps {
|
||||
onUpdateConfig: (updates: Partial<ProjectEnvConfig>) => void;
|
||||
gitHubConnectionStatus: GitHubSyncStatus | null;
|
||||
isCheckingGitHub: boolean;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function GitHubIntegrationSection({
|
||||
@@ -28,6 +29,7 @@ export function GitHubIntegrationSection({
|
||||
onUpdateConfig,
|
||||
gitHubConnectionStatus,
|
||||
isCheckingGitHub,
|
||||
projectName,
|
||||
}: GitHubIntegrationSectionProps) {
|
||||
const [showOAuthFlow, setShowOAuthFlow] = useState(false);
|
||||
|
||||
@@ -48,6 +50,22 @@ export function GitHubIntegrationSection({
|
||||
onToggle={onToggle}
|
||||
badge={badge}
|
||||
>
|
||||
{/* Project-Specific Configuration Notice */}
|
||||
{projectName && (
|
||||
<div className="rounded-lg border border-info/30 bg-info/5 p-3 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-info mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Project-Specific Configuration</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
This GitHub repository is configured only for <span className="font-semibold text-foreground">{projectName}</span>.
|
||||
Each project can have its own GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="font-normal text-foreground">Enable GitHub Issues</Label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Github,
|
||||
Loader2,
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Terminal
|
||||
Terminal,
|
||||
Copy,
|
||||
Check,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
@@ -29,6 +32,10 @@ function debugLog(message: string, data?: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication timeout in milliseconds (5 minutes)
|
||||
// GitHub device codes typically expire after 15 minutes, but 5 minutes is a reasonable UX timeout
|
||||
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* GitHub OAuth flow component using gh CLI
|
||||
* Guides users through authenticating with GitHub using the gh CLI
|
||||
@@ -40,10 +47,54 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
const [cliVersion, setCliVersion] = useState<string | undefined>();
|
||||
const [username, setUsername] = useState<string | undefined>();
|
||||
|
||||
// Device flow state for displaying code and auth URL
|
||||
const [deviceCode, setDeviceCode] = useState<string | null>(null);
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [browserOpened, setBrowserOpened] = useState<boolean>(false);
|
||||
const [codeCopied, setCodeCopied] = useState<boolean>(false);
|
||||
const [urlCopied, setUrlCopied] = useState<boolean>(false);
|
||||
const [isTimeout, setIsTimeout] = useState<boolean>(false);
|
||||
|
||||
// Ref to track authentication timeout
|
||||
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Refs to track copy feedback timeouts
|
||||
const codeCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const urlCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Check gh CLI installation and authentication status on mount
|
||||
// Use a ref to prevent double-execution in React Strict Mode
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
// Clear the authentication timeout
|
||||
const clearAuthTimeout = useCallback(() => {
|
||||
if (authTimeoutRef.current) {
|
||||
debugLog('Clearing auth timeout');
|
||||
clearTimeout(authTimeoutRef.current);
|
||||
authTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup copy feedback timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (codeCopyTimeoutRef.current) {
|
||||
clearTimeout(codeCopyTimeoutRef.current);
|
||||
}
|
||||
if (urlCopyTimeoutRef.current) {
|
||||
clearTimeout(urlCopyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle authentication timeout
|
||||
const handleAuthTimeout = useCallback(() => {
|
||||
debugLog('Authentication timeout triggered after 5 minutes');
|
||||
setIsTimeout(true);
|
||||
setError('Authentication timed out. The authentication window was open for too long. Please try again.');
|
||||
setStatus('error');
|
||||
authTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCheckedRef.current) {
|
||||
debugLog('Skipping duplicate check (Strict Mode)');
|
||||
@@ -52,8 +103,13 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
hasCheckedRef.current = true;
|
||||
debugLog('Component mounted, checking GitHub status...');
|
||||
checkGitHubStatus();
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return () => {
|
||||
clearAuthTimeout();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once on mount, checkGitHubStatus is intentionally excluded
|
||||
}, []);
|
||||
}, [clearAuthTimeout]);
|
||||
|
||||
const checkGitHubStatus = async () => {
|
||||
debugLog('checkGitHubStatus() called');
|
||||
@@ -138,21 +194,59 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
setStatus('authenticating');
|
||||
setError(null);
|
||||
|
||||
// Reset device flow state
|
||||
setDeviceCode(null);
|
||||
setAuthUrl(null);
|
||||
setBrowserOpened(false);
|
||||
setCodeCopied(false);
|
||||
setUrlCopied(false);
|
||||
setIsTimeout(false);
|
||||
|
||||
// Clear any existing timeout and start a new one
|
||||
clearAuthTimeout();
|
||||
debugLog(`Starting auth timeout (${AUTH_TIMEOUT_MS / 1000 / 60} minutes)`);
|
||||
authTimeoutRef.current = setTimeout(handleAuthTimeout, AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
debugLog('Calling startGitHubAuth...');
|
||||
const result = await window.electronAPI.startGitHubAuth();
|
||||
debugLog('startGitHubAuth result:', result);
|
||||
|
||||
// Clear timeout since we got a response
|
||||
clearAuthTimeout();
|
||||
|
||||
// Capture device flow info if available
|
||||
if (result.data?.deviceCode) {
|
||||
debugLog('Device code received:', result.data.deviceCode);
|
||||
setDeviceCode(result.data.deviceCode);
|
||||
}
|
||||
if (result.data?.authUrl) {
|
||||
debugLog('Auth URL received:', result.data.authUrl);
|
||||
setAuthUrl(result.data.authUrl);
|
||||
}
|
||||
if (result.data?.browserOpened !== undefined) {
|
||||
debugLog('Browser opened status:', result.data.browserOpened);
|
||||
setBrowserOpened(result.data.browserOpened);
|
||||
}
|
||||
|
||||
if (result.success && result.data?.success) {
|
||||
debugLog('Auth successful, fetching token...');
|
||||
// Fetch the token and notify parent
|
||||
await fetchAndNotifyToken();
|
||||
} else {
|
||||
debugLog('Auth failed:', result.error);
|
||||
setError(result.error || 'Authentication failed');
|
||||
// Include fallback URL info in error message if available
|
||||
const errorMessage = result.error || 'Authentication failed';
|
||||
setError(errorMessage);
|
||||
// Keep authUrl from response for fallback display
|
||||
if (result.data?.fallbackUrl) {
|
||||
setAuthUrl(result.data.fallbackUrl);
|
||||
}
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
// Clear timeout on error
|
||||
clearAuthTimeout();
|
||||
debugLog('Error in handleStartAuth:', err);
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
setStatus('error');
|
||||
@@ -169,6 +263,30 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
checkGitHubStatus();
|
||||
};
|
||||
|
||||
const handleCopyDeviceCode = async () => {
|
||||
if (!deviceCode) return;
|
||||
debugLog('Copying device code to clipboard');
|
||||
try {
|
||||
await navigator.clipboard.writeText(deviceCode);
|
||||
setCodeCopied(true);
|
||||
// Clear any existing timeout before setting a new one
|
||||
if (codeCopyTimeoutRef.current) {
|
||||
clearTimeout(codeCopyTimeoutRef.current);
|
||||
}
|
||||
// Reset the copied state after 2 seconds
|
||||
codeCopyTimeoutRef.current = setTimeout(() => setCodeCopied(false), 2000);
|
||||
} catch (err) {
|
||||
debugLog('Failed to copy device code:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAuthUrl = () => {
|
||||
if (authUrl) {
|
||||
debugLog('Opening auth URL manually:', authUrl);
|
||||
window.open(authUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
debugLog('Rendering with status:', status);
|
||||
|
||||
return (
|
||||
@@ -263,21 +381,81 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
|
||||
{/* Authenticating */}
|
||||
{status === 'authenticating' && (
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticating...
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Please complete the authentication in your browser. This window will update automatically.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authenticating...
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{browserOpened
|
||||
? 'Please complete the authentication in your browser. This window will update automatically.'
|
||||
: 'Waiting for authentication flow to start...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Code Display */}
|
||||
{deviceCode && (
|
||||
<Card className="border border-primary/30 bg-primary/5">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Your one-time code
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code className="text-3xl font-mono font-bold tracking-widest text-primary px-4 py-2 bg-primary/10 rounded-lg">
|
||||
{deviceCode}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyDeviceCode}
|
||||
className="shrink-0"
|
||||
>
|
||||
{codeCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-success" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
{browserOpened
|
||||
? 'Enter this code in your browser to complete authentication.'
|
||||
: 'Copy this code, then open the link below to authenticate.'}
|
||||
</p>
|
||||
{!browserOpened && authUrl && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={handleOpenAuthUrl}
|
||||
className="text-info hover:text-info/80 p-0 h-auto gap-1"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open {authUrl}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
@@ -302,22 +480,106 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
{/* Error */}
|
||||
{status === 'error' && error && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-destructive/30 bg-destructive/10">
|
||||
<Card className={`border ${isTimeout ? 'border-warning/30 bg-warning/10' : 'border-destructive/30 bg-destructive/10'}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
{isTimeout ? (
|
||||
<Clock className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-destructive">
|
||||
Authentication Failed
|
||||
<h3 className={`text-lg font-medium ${isTimeout ? 'text-warning' : 'text-destructive'}`}>
|
||||
{isTimeout ? 'Authentication Timed Out' : 'Authentication Failed'}
|
||||
</h3>
|
||||
<p className="text-sm text-destructive/80 mt-1">{error}</p>
|
||||
<p className={`text-sm mt-1 ${isTimeout ? 'text-warning/80' : 'text-destructive/80'}`}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fallback URL display when browser failed to open */}
|
||||
{authUrl && (
|
||||
<Card className="border border-warning/30 bg-warning/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-foreground">
|
||||
Complete Authentication Manually
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The browser couldn't be opened automatically. Please visit the URL below to complete authentication:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
|
||||
<code className="text-sm font-mono text-foreground flex-1 break-all">
|
||||
{authUrl}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
setUrlCopied(true);
|
||||
// Clear any existing timeout before setting a new one
|
||||
if (urlCopyTimeoutRef.current) {
|
||||
clearTimeout(urlCopyTimeoutRef.current);
|
||||
}
|
||||
urlCopyTimeoutRef.current = setTimeout(() => setUrlCopied(false), 2000);
|
||||
} catch (err) {
|
||||
debugLog('Failed to copy URL:', err);
|
||||
}
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1 text-success" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleOpenAuthUrl}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open URL in Browser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Device code reminder if available */}
|
||||
{deviceCode && (
|
||||
<div className="pt-2 border-t border-warning/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When prompted, enter this code:{' '}
|
||||
<code className="font-mono font-bold text-primary px-2 py-0.5 bg-primary/10 rounded">
|
||||
{deviceCode}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button onClick={handleRetry} variant="outline">
|
||||
<Button onClick={handleStartAuth} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
{onCancel && (
|
||||
|
||||
@@ -146,7 +146,7 @@ export function MemoryBackendSection({
|
||||
onValueChange={(value) => onUpdateConfig({
|
||||
graphitiProviderConfig: {
|
||||
...envConfig.graphitiProviderConfig,
|
||||
llmProvider: value as 'openai' | 'anthropic' | 'google' | 'groq',
|
||||
llmProvider: value as 'openai' | 'anthropic' | 'azure_openai' | 'ollama' | 'google' | 'groq',
|
||||
embeddingProvider: envConfig.graphitiProviderConfig?.embeddingProvider || 'openai',
|
||||
}
|
||||
})}
|
||||
@@ -155,10 +155,11 @@ export function MemoryBackendSection({
|
||||
<SelectValue placeholder="Select LLM provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI (GPT-5-mini)</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (GPT-4o-mini)</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic (Claude)</SelectItem>
|
||||
<SelectItem value="google">Google (Gemini)</SelectItem>
|
||||
<SelectItem value="groq">Groq (Llama)</SelectItem>
|
||||
<SelectItem value="google">Google AI (Gemini)</SelectItem>
|
||||
<SelectItem value="azure_openai">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (Local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -175,7 +176,7 @@ export function MemoryBackendSection({
|
||||
graphitiProviderConfig: {
|
||||
...envConfig.graphitiProviderConfig,
|
||||
llmProvider: envConfig.graphitiProviderConfig?.llmProvider || 'openai',
|
||||
embeddingProvider: value as 'openai' | 'voyage' | 'google' | 'huggingface',
|
||||
embeddingProvider: value as 'openai' | 'voyage' | 'azure_openai' | 'ollama' | 'google' | 'huggingface',
|
||||
}
|
||||
})}
|
||||
>
|
||||
@@ -185,8 +186,9 @@ export function MemoryBackendSection({
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="voyage">Voyage AI</SelectItem>
|
||||
<SelectItem value="google">Google</SelectItem>
|
||||
<SelectItem value="huggingface">HuggingFace (Local)</SelectItem>
|
||||
<SelectItem value="google">Google AI</SelectItem>
|
||||
<SelectItem value="azure_openai">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (Local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@ export function SecuritySettings({
|
||||
updateEnvConfig({
|
||||
graphitiProviderConfig: {
|
||||
...currentConfig,
|
||||
llmProvider: value as 'openai' | 'anthropic' | 'google' | 'groq',
|
||||
llmProvider: value as 'openai' | 'anthropic' | 'azure_openai' | 'ollama' | 'google' | 'groq',
|
||||
}
|
||||
});
|
||||
}}
|
||||
@@ -173,10 +173,11 @@ export function SecuritySettings({
|
||||
<SelectValue placeholder="Select LLM provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI (GPT-5-mini)</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (GPT-4o-mini)</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic (Claude)</SelectItem>
|
||||
<SelectItem value="google">Google (Gemini)</SelectItem>
|
||||
<SelectItem value="groq">Groq (Llama)</SelectItem>
|
||||
<SelectItem value="google">Google AI (Gemini)</SelectItem>
|
||||
<SelectItem value="azure_openai">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (Local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -197,7 +198,7 @@ export function SecuritySettings({
|
||||
updateEnvConfig({
|
||||
graphitiProviderConfig: {
|
||||
...currentConfig,
|
||||
embeddingProvider: value as 'openai' | 'voyage' | 'google' | 'huggingface',
|
||||
embeddingProvider: value as 'openai' | 'voyage' | 'azure_openai' | 'ollama' | 'google' | 'huggingface',
|
||||
}
|
||||
});
|
||||
}}
|
||||
@@ -208,8 +209,9 @@ export function SecuritySettings({
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="voyage">Voyage AI</SelectItem>
|
||||
<SelectItem value="google">Google</SelectItem>
|
||||
<SelectItem value="huggingface">HuggingFace (Local)</SelectItem>
|
||||
<SelectItem value="google">Google AI</SelectItem>
|
||||
<SelectItem value="azure_openai">Azure OpenAI</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (Local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
@@ -8,10 +9,12 @@ import {
|
||||
Zap,
|
||||
ExternalLink,
|
||||
TrendingUp,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import {
|
||||
ROADMAP_PRIORITY_COLORS,
|
||||
ROADMAP_PRIORITY_LABELS,
|
||||
@@ -25,15 +28,25 @@ export function FeatureDetailPanel({
|
||||
onClose,
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
onDelete,
|
||||
competitorInsights = [],
|
||||
}: FeatureDetailPanelProps) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (onDelete) {
|
||||
onDelete(feature.id);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-96 bg-card border-l border-border shadow-lg flex flex-col z-50">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 p-4 border-b border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="shrink-0 p-4 border-b border-border electron-no-drag">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Badge variant="outline" className={ROADMAP_PRIORITY_COLORS[feature.priority]}>
|
||||
{ROADMAP_PRIORITY_LABELS[feature.priority]}
|
||||
</Badge>
|
||||
@@ -44,21 +57,36 @@ export function FeatureDetailPanel({
|
||||
{feature.complexity}
|
||||
</Badge>
|
||||
</div>
|
||||
<h2 className="font-semibold">{feature.title}</h2>
|
||||
<h2 className="font-semibold truncate">{feature.title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 relative z-10 pointer-events-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onClose}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Description</h3>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Rationale */}
|
||||
<div>
|
||||
@@ -179,7 +207,8 @@ export function FeatureDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Actions */}
|
||||
{feature.linkedSpecId ? (
|
||||
@@ -199,6 +228,31 @@ export function FeatureDetailPanel({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 bg-background/95 flex items-center justify-center p-6 z-10">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
|
||||
<Trash2 className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Delete Feature?</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This will permanently remove "{feature.title}" from your roadmap.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,30 +65,32 @@ export function RoadmapHeader({ roadmap, competitorAnalysis, onAddFeature, onRef
|
||||
</div>
|
||||
|
||||
{/* Target Audience */}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Target:</span>
|
||||
<span className="font-medium">{roadmap.targetAudience.primary}</span>
|
||||
{roadmap.targetAudience && (
|
||||
<div className="mt-4 flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Target:</span>
|
||||
<span className="font-medium">{roadmap.targetAudience.primary}</span>
|
||||
</div>
|
||||
{roadmap.targetAudience.secondary?.length > 0 && (
|
||||
<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>
|
||||
{roadmap.targetAudience.secondary.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-4 flex items-center gap-6">
|
||||
|
||||
@@ -23,16 +23,28 @@ export function RoadmapTabs({
|
||||
onFeatureSelect,
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
onSave,
|
||||
}: RoadmapTabsProps) {
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange} className="h-full flex flex-col">
|
||||
<TabsList className="shrink-0 mx-4 mt-4">
|
||||
<TabsTrigger value="kanban">Kanban</TabsTrigger>
|
||||
<TabsTrigger value="phases">Phases</TabsTrigger>
|
||||
<TabsTrigger value="features">All Features</TabsTrigger>
|
||||
<TabsTrigger value="priorities">By Priority</TabsTrigger>
|
||||
<TabsTrigger value="kanban">Kanban</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Kanban View */}
|
||||
<TabsContent value="kanban" className="flex-1 overflow-hidden">
|
||||
<RoadmapKanbanView
|
||||
roadmap={roadmap}
|
||||
onFeatureClick={onFeatureSelect}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Phases View */}
|
||||
<TabsContent value="phases" className="flex-1 overflow-auto p-4">
|
||||
<div className="space-y-6">
|
||||
@@ -115,16 +127,6 @@ export function RoadmapTabs({
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Kanban View */}
|
||||
<TabsContent value="kanban" className="flex-1 overflow-hidden">
|
||||
<RoadmapKanbanView
|
||||
roadmap={roadmap}
|
||||
onFeatureClick={onFeatureSelect}
|
||||
onConvertToSpec={onConvertToSpec}
|
||||
onGoToTask={onGoToTask}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useFeatureActions() {
|
||||
setSelectedFeature({
|
||||
...feature,
|
||||
linkedSpecId: result.data.specId,
|
||||
status: 'planned',
|
||||
status: 'in_progress',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,23 +68,87 @@ export function useFeatureActions() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to save roadmap changes to disk
|
||||
*
|
||||
* NOTE: Gets roadmap from store at call time (not render time) to ensure
|
||||
* we save the latest state after Zustand updates (e.g., after drag-drop status change)
|
||||
*/
|
||||
export function useRoadmapSave(projectId: string) {
|
||||
const saveRoadmap = async () => {
|
||||
// Get current state at call time to avoid stale closure issues
|
||||
const roadmap = useRoadmapStore.getState().roadmap;
|
||||
if (!roadmap) return;
|
||||
|
||||
try {
|
||||
await window.electronAPI.saveRoadmap(projectId, roadmap);
|
||||
} catch (error) {
|
||||
console.error('Failed to save roadmap:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { saveRoadmap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete features from roadmap
|
||||
*/
|
||||
export function useFeatureDelete(projectId: string) {
|
||||
const deleteFeature = useRoadmapStore((state) => state.deleteFeature);
|
||||
|
||||
const handleDeleteFeature = async (featureId: string) => {
|
||||
// Delete from store
|
||||
deleteFeature(featureId);
|
||||
|
||||
// Persist to file
|
||||
const roadmap = useRoadmapStore.getState().roadmap;
|
||||
if (roadmap) {
|
||||
try {
|
||||
await window.electronAPI.saveRoadmap(projectId, roadmap);
|
||||
} catch (error) {
|
||||
console.error('Failed to save roadmap after delete:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteFeature: handleDeleteFeature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage roadmap generation actions
|
||||
*
|
||||
* Handles two scenarios:
|
||||
* 1. No existing competitor analysis: Show simple enable/skip dialog
|
||||
* 2. Existing competitor analysis: Show options to use existing, run new, or skip
|
||||
*/
|
||||
export function useRoadmapGeneration(projectId: string) {
|
||||
const competitorAnalysis = useRoadmapStore((state) => state.competitorAnalysis);
|
||||
const [pendingAction, setPendingAction] = useState<'generate' | 'refresh' | null>(null);
|
||||
const [showCompetitorDialog, setShowCompetitorDialog] = useState(false);
|
||||
const [showExistingAnalysisDialog, setShowExistingAnalysisDialog] = useState(false);
|
||||
|
||||
// Check if we have existing competitor analysis
|
||||
const hasExistingAnalysis = !!competitorAnalysis;
|
||||
|
||||
const handleGenerate = () => {
|
||||
setPendingAction('generate');
|
||||
setShowCompetitorDialog(true);
|
||||
if (hasExistingAnalysis) {
|
||||
setShowExistingAnalysisDialog(true);
|
||||
} else {
|
||||
setShowCompetitorDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setPendingAction('refresh');
|
||||
setShowCompetitorDialog(true);
|
||||
if (hasExistingAnalysis) {
|
||||
setShowExistingAnalysisDialog(true);
|
||||
} else {
|
||||
setShowCompetitorDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for "Yes, Enable Analysis" (new competitor analysis)
|
||||
const handleCompetitorDialogAccept = () => {
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId, true); // Enable competitor analysis
|
||||
@@ -94,6 +158,7 @@ export function useRoadmapGeneration(projectId: string) {
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// Handler for "No, Skip Analysis"
|
||||
const handleCompetitorDialogDecline = () => {
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId, false); // Disable competitor analysis
|
||||
@@ -103,12 +168,53 @@ export function useRoadmapGeneration(projectId: string) {
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// Handler for "Use existing analysis" - reuses saved competitor data
|
||||
const handleUseExistingAnalysis = () => {
|
||||
// Enable competitor analysis but don't force refresh - backend will use existing if available
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId, true, false); // enableCompetitorAnalysis=true, refreshCompetitorAnalysis=false
|
||||
} else if (pendingAction === 'refresh') {
|
||||
refreshRoadmap(projectId, true, false); // enableCompetitorAnalysis=true, refreshCompetitorAnalysis=false
|
||||
}
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// Handler for "Run new analysis" - performs fresh web searches
|
||||
const handleRunNewAnalysis = () => {
|
||||
// Enable competitor analysis AND force refresh to run fresh web searches
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId, true, true); // enableCompetitorAnalysis=true, refreshCompetitorAnalysis=true
|
||||
} else if (pendingAction === 'refresh') {
|
||||
refreshRoadmap(projectId, true, true); // enableCompetitorAnalysis=true, refreshCompetitorAnalysis=true
|
||||
}
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
// Handler for "Skip analysis"
|
||||
const handleSkipAnalysis = () => {
|
||||
if (pendingAction === 'generate') {
|
||||
generateRoadmap(projectId, false);
|
||||
} else if (pendingAction === 'refresh') {
|
||||
refreshRoadmap(projectId, false);
|
||||
}
|
||||
setPendingAction(null);
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await stopRoadmap(projectId);
|
||||
};
|
||||
|
||||
return {
|
||||
pendingAction,
|
||||
hasExistingAnalysis,
|
||||
competitorAnalysisDate: competitorAnalysis?.createdAt,
|
||||
// New dialog for existing analysis
|
||||
showExistingAnalysisDialog,
|
||||
setShowExistingAnalysisDialog,
|
||||
handleUseExistingAnalysis,
|
||||
handleRunNewAnalysis,
|
||||
handleSkipAnalysis,
|
||||
// Original dialog for no existing analysis
|
||||
showCompetitorDialog,
|
||||
setShowCompetitorDialog,
|
||||
handleGenerate,
|
||||
|
||||
@@ -4,6 +4,6 @@ export { RoadmapTabs } from './RoadmapTabs';
|
||||
export { PhaseCard } from './PhaseCard';
|
||||
export { FeatureCard } from './FeatureCard';
|
||||
export { FeatureDetailPanel } from './FeatureDetailPanel';
|
||||
export { useRoadmapData, useFeatureActions, useRoadmapGeneration } from './hooks';
|
||||
export { useRoadmapData, useFeatureActions, useRoadmapGeneration, useRoadmapSave, useFeatureDelete } from './hooks';
|
||||
export { getCompetitorInsightsForFeature, hasCompetitorInsight } from './utils';
|
||||
export type * from './types';
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface FeatureDetailPanelProps {
|
||||
onClose: () => void;
|
||||
onConvertToSpec: (feature: RoadmapFeature) => void;
|
||||
onGoToTask: (specId: string) => void;
|
||||
onDelete?: (featureId: string) => void;
|
||||
competitorInsights?: CompetitorPainPoint[];
|
||||
}
|
||||
|
||||
@@ -49,4 +50,5 @@ export interface RoadmapTabsProps {
|
||||
onFeatureSelect: (feature: RoadmapFeature) => void;
|
||||
onConvertToSpec: (feature: RoadmapFeature) => void;
|
||||
onGoToTask: (specId: string) => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
const [isCheckingSourceUpdate, setIsCheckingSourceUpdate] = useState(false);
|
||||
const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<AutoBuildSourceUpdateProgress | null>(null);
|
||||
// Local version state that can be updated after successful update
|
||||
const [displayVersion, setDisplayVersion] = useState<string>(version);
|
||||
|
||||
// Electron app update state
|
||||
const [appUpdateInfo, setAppUpdateInfo] = useState<AppUpdateAvailableEvent | null>(null);
|
||||
@@ -84,6 +86,11 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
const [appDownloadProgress, setAppDownloadProgress] = useState<AppUpdateProgress | null>(null);
|
||||
const [isAppUpdateDownloaded, setIsAppUpdateDownloaded] = useState(false);
|
||||
|
||||
// Sync displayVersion with prop when it changes
|
||||
useEffect(() => {
|
||||
setDisplayVersion(version);
|
||||
}, [version]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (section === 'updates') {
|
||||
@@ -98,6 +105,10 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
setDownloadProgress(progress);
|
||||
if (progress.stage === 'complete') {
|
||||
setIsDownloadingUpdate(false);
|
||||
// Update the displayed version if a new version was provided
|
||||
if (progress.newVersion) {
|
||||
setDisplayVersion(progress.newVersion);
|
||||
}
|
||||
checkForSourceUpdates();
|
||||
} else if (progress.stage === 'error') {
|
||||
setIsDownloadingUpdate(false);
|
||||
@@ -164,14 +175,20 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
};
|
||||
|
||||
const checkForSourceUpdates = async () => {
|
||||
console.log('[AdvancedSettings] Checking for source updates...');
|
||||
setIsCheckingSourceUpdate(true);
|
||||
try {
|
||||
const result = await window.electronAPI.checkAutoBuildSourceUpdate();
|
||||
console.log('[AdvancedSettings] Check result:', result);
|
||||
if (result.success && result.data) {
|
||||
setSourceUpdateCheck(result.data);
|
||||
// Update displayed version from the check result (most accurate)
|
||||
if (result.data.currentVersion) {
|
||||
setDisplayVersion(result.data.currentVersion);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent fail - user can retry via button
|
||||
console.error('[AdvancedSettings] Check error:', err);
|
||||
} finally {
|
||||
setIsCheckingSourceUpdate(false);
|
||||
}
|
||||
@@ -287,7 +304,7 @@ export function AdvancedSettings({ settings, onSettingsChange, section, version
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Version</p>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{version || 'Loading...'}
|
||||
{displayVersion || 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
{isCheckingSourceUpdate ? (
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_PHASE_THINKING
|
||||
} from '../../../shared/constants';
|
||||
import { useSettingsStore, saveSettings } from '../../stores/settings-store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../ui/select';
|
||||
import type { AgentProfile, PhaseModelConfig, PhaseThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types/settings';
|
||||
|
||||
/**
|
||||
* Icon mapping for agent profile icons
|
||||
*/
|
||||
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' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent Profile Settings component
|
||||
* Displays preset agent profiles for quick model/thinking level configuration
|
||||
* Used in the Settings page under Agent Settings
|
||||
*/
|
||||
export function AgentProfileSettings() {
|
||||
const settings = useSettingsStore((state) => state.settings);
|
||||
const selectedProfileId = settings.selectedAgentProfile || 'auto';
|
||||
const [showPhaseConfig, setShowPhaseConfig] = useState(selectedProfileId === 'auto');
|
||||
|
||||
// Get current phase config from settings or defaults
|
||||
const currentPhaseModels: PhaseModelConfig = settings.customPhaseModels || DEFAULT_PHASE_MODELS;
|
||||
const currentPhaseThinking: PhaseThinkingConfig = settings.customPhaseThinking || DEFAULT_PHASE_THINKING;
|
||||
|
||||
const handleSelectProfile = async (profileId: string) => {
|
||||
const success = await saveSettings({ selectedAgentProfile: profileId });
|
||||
if (!success) {
|
||||
// Log error for debugging - in future could show user toast notification
|
||||
console.error('Failed to save agent profile selection');
|
||||
return;
|
||||
}
|
||||
// Auto-expand phase config when Auto profile is selected
|
||||
if (profileId === 'auto') {
|
||||
setShowPhaseConfig(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseModelChange = async (phase: keyof PhaseModelConfig, value: ModelTypeShort) => {
|
||||
const newPhaseModels = { ...currentPhaseModels, [phase]: value };
|
||||
await saveSettings({ customPhaseModels: newPhaseModels });
|
||||
};
|
||||
|
||||
const handlePhaseThinkingChange = async (phase: keyof PhaseThinkingConfig, value: ThinkingLevel) => {
|
||||
const newPhaseThinking = { ...currentPhaseThinking, [phase]: value };
|
||||
await saveSettings({ customPhaseThinking: newPhaseThinking });
|
||||
};
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
await saveSettings({
|
||||
customPhaseModels: DEFAULT_PHASE_MODELS,
|
||||
customPhaseThinking: DEFAULT_PHASE_THINKING
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get human-readable model label
|
||||
*/
|
||||
const getModelLabel = (modelValue: string): string => {
|
||||
const model = AVAILABLE_MODELS.find((m) => m.value === modelValue);
|
||||
return model?.label || modelValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get human-readable thinking level label
|
||||
*/
|
||||
const getThinkingLabel = (thinkingValue: string): string => {
|
||||
const level = THINKING_LEVELS.find((l) => l.value === thinkingValue);
|
||||
return level?.label || thinkingValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current phase config differs from defaults
|
||||
*/
|
||||
const hasCustomConfig = (): boolean => {
|
||||
const phases: Array<keyof PhaseModelConfig> = ['spec', 'planning', 'coding', 'qa'];
|
||||
return phases.some(
|
||||
phase =>
|
||||
currentPhaseModels[phase] !== DEFAULT_PHASE_MODELS[phase] ||
|
||||
currentPhaseThinking[phase] !== DEFAULT_PHASE_THINKING[phase]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single profile card
|
||||
*/
|
||||
const renderProfileCard = (profile: AgentProfile) => {
|
||||
const isSelected = selectedProfileId === profile.id;
|
||||
const Icon = iconMap[profile.icon || 'Brain'] || Brain;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => handleSelectProfile(profile.id)}
|
||||
className={cn(
|
||||
'relative w-full rounded-lg border p-4 text-left transition-all duration-200',
|
||||
'hover:border-primary/50 hover:shadow-sm',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card'
|
||||
)}
|
||||
>
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile content */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg shrink-0',
|
||||
isSelected ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pr-6">
|
||||
<h3 className="font-medium text-sm text-foreground">{profile.name}</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{profile.description}
|
||||
</p>
|
||||
|
||||
{/* Model and thinking level badges */}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{getModelLabel(profile.model)}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{getThinkingLabel(profile.thinkingLevel)} Thinking
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Default Agent Profile"
|
||||
description="Select a preset configuration for model and thinking level"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Description */}
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Agent profiles provide preset configurations for Claude model and thinking level.
|
||||
When you create a new task, these settings will be used as defaults. You can always
|
||||
override them in the task creation wizard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile cards - 2 column grid on larger screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{DEFAULT_AGENT_PROFILES.map(renderProfileCard)}
|
||||
</div>
|
||||
|
||||
{/* Phase Configuration (only for Auto profile) */}
|
||||
{selectedProfileId === 'auto' && (
|
||||
<div className="mt-6 rounded-lg border border-border bg-card">
|
||||
{/* Header - Collapsible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPhaseConfig(!showPhaseConfig)}
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors rounded-t-lg"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm text-foreground">Phase Configuration</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Customize model and thinking level for each phase
|
||||
</p>
|
||||
</div>
|
||||
{showPhaseConfig ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Phase Configuration Content */}
|
||||
{showPhaseConfig && (
|
||||
<div className="border-t border-border p-4 space-y-4">
|
||||
{/* Reset button */}
|
||||
{hasCustomConfig() && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetToDefaults}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1.5" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase Configuration Grid */}
|
||||
<div className="space-y-4">
|
||||
{(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-sm font-medium text-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-3">
|
||||
{/* Model Select */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<Select
|
||||
value={currentPhaseModels[phase]}
|
||||
onValueChange={(value) => handlePhaseModelChange(phase, value as ModelTypeShort)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Thinking Level Select */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Thinking Level</Label>
|
||||
<Select
|
||||
value={currentPhaseThinking[phase]}
|
||||
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<p className="text-[10px] text-muted-foreground mt-4 pt-3 border-t border-border">
|
||||
These settings will be used as defaults when creating new tasks with the Auto profile.
|
||||
You can override them per-task in the task creation wizard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ const projectNavItems: NavItem<ProjectSettingsSection>[] = [
|
||||
* Coordinates app and project settings sections
|
||||
*/
|
||||
export function AppSettingsDialog({ open, onOpenChange, initialSection, initialProjectSection, onRerunWizard }: AppSettingsDialogProps) {
|
||||
const { settings, setSettings, isSaving, error, saveSettings } = useSettings();
|
||||
const { settings, setSettings, isSaving, error, saveSettings, revertTheme, commitTheme } = useSettings();
|
||||
const [version, setVersion] = useState<string>('');
|
||||
|
||||
// Track which top-level section is active
|
||||
@@ -138,10 +138,17 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
|
||||
}
|
||||
|
||||
if (appSaveSuccess) {
|
||||
// Commit the theme so future cancels won't revert to old values
|
||||
commitTheme();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// onOpenChange handler will revert theme changes
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleProjectChange = (projectId: string | null) => {
|
||||
selectProject(projectId);
|
||||
};
|
||||
@@ -183,7 +190,14 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
|
||||
const projectNavDisabled = !selectedProjectId;
|
||||
|
||||
return (
|
||||
<FullScreenDialog open={open} onOpenChange={onOpenChange}>
|
||||
<FullScreenDialog open={open} onOpenChange={(newOpen) => {
|
||||
if (!newOpen) {
|
||||
// Dialog is being closed (via X, escape, or overlay click)
|
||||
// Revert any unsaved theme changes
|
||||
revertTheme();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}>
|
||||
<FullScreenDialogContent>
|
||||
<FullScreenDialogHeader>
|
||||
<FullScreenDialogTitle className="flex items-center gap-3">
|
||||
@@ -332,7 +346,7 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
|
||||
{error || projectError}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -3,8 +3,15 @@ import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { AVAILABLE_MODELS } from '../../../shared/constants';
|
||||
import type { AppSettings } from '../../../shared/types';
|
||||
import { AgentProfileSettings } from './AgentProfileSettings';
|
||||
import {
|
||||
AVAILABLE_MODELS,
|
||||
THINKING_LEVELS,
|
||||
DEFAULT_FEATURE_MODELS,
|
||||
DEFAULT_FEATURE_THINKING,
|
||||
FEATURE_LABELS
|
||||
} from '../../../shared/constants';
|
||||
import type { AppSettings, FeatureModelConfig, FeatureThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types';
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
settings: AppSettings;
|
||||
@@ -18,64 +25,125 @@ interface GeneralSettingsProps {
|
||||
export function GeneralSettings({ settings, onSettingsChange, section }: GeneralSettingsProps) {
|
||||
if (section === 'agent') {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Default Agent Settings"
|
||||
description="Configure defaults for new projects"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="defaultModel" className="text-sm font-medium text-foreground">Default Model</Label>
|
||||
<p className="text-sm text-muted-foreground">The AI model used for agent tasks</p>
|
||||
<Select
|
||||
value={settings.defaultModel}
|
||||
onValueChange={(value) => onSettingsChange({ ...settings, defaultModel: value })}
|
||||
>
|
||||
<SelectTrigger id="defaultModel" className="w-full max-w-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="agentFramework" className="text-sm font-medium text-foreground">Agent Framework</Label>
|
||||
<p className="text-sm text-muted-foreground">The coding framework used for autonomous tasks</p>
|
||||
<Select
|
||||
value={settings.agentFramework}
|
||||
onValueChange={(value) => onSettingsChange({ ...settings, agentFramework: value })}
|
||||
>
|
||||
<SelectTrigger id="agentFramework" className="w-full max-w-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto-claude">Auto Claude</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between max-w-md">
|
||||
<div className="space-y-8">
|
||||
{/* Agent Profile Selection */}
|
||||
<AgentProfileSettings />
|
||||
|
||||
{/* Other Agent Settings */}
|
||||
<SettingsSection
|
||||
title="Other Agent Settings"
|
||||
description="Additional agent configuration options"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="agentFramework" className="text-sm font-medium text-foreground">Agent Framework</Label>
|
||||
<p className="text-sm text-muted-foreground">The coding framework used for autonomous tasks</p>
|
||||
<Select
|
||||
value={settings.agentFramework}
|
||||
onValueChange={(value) => onSettingsChange({ ...settings, agentFramework: value })}
|
||||
>
|
||||
<SelectTrigger id="agentFramework" className="w-full max-w-md">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto-claude">Auto Claude</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between max-w-md">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
|
||||
AI Terminal Naming
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically name terminals based on commands (uses Haiku)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoNameTerminals"
|
||||
checked={settings.autoNameTerminals}
|
||||
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Model Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-border">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="autoNameTerminals" className="text-sm font-medium text-foreground">
|
||||
AI Terminal Naming
|
||||
</Label>
|
||||
<Label className="text-sm font-medium text-foreground">Feature Model Settings</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically name terminals based on commands (uses Haiku)
|
||||
Model and thinking level for Insights, Ideation, and Roadmap
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="autoNameTerminals"
|
||||
checked={settings.autoNameTerminals}
|
||||
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
|
||||
/>
|
||||
|
||||
{(Object.keys(FEATURE_LABELS) as Array<keyof FeatureModelConfig>).map((feature) => {
|
||||
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
|
||||
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
|
||||
|
||||
return (
|
||||
<div key={feature} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
{FEATURE_LABELS[feature].label}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{FEATURE_LABELS[feature].description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-md">
|
||||
{/* Model Select */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<Select
|
||||
value={featureModels[feature]}
|
||||
onValueChange={(value) => {
|
||||
const newFeatureModels = { ...featureModels, [feature]: value as ModelTypeShort };
|
||||
onSettingsChange({ ...settings, featureModels: newFeatureModels });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Thinking Level Select */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Thinking Level</Label>
|
||||
<Select
|
||||
value={featureThinking[feature]}
|
||||
onValueChange={(value) => {
|
||||
const newFeatureThinking = { ...featureThinking, [feature]: value as ThinkingLevel };
|
||||
onSettingsChange({ ...settings, featureThinking: newFeatureThinking });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -404,8 +404,9 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
|
||||
</div>
|
||||
{editingProfileId !== profile.id && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Authenticate button - show if not authenticated */}
|
||||
{!profile.oauthToken && (
|
||||
{/* Authenticate button - show only if NOT authenticated */}
|
||||
{/* A profile is authenticated if: has OAuth token OR (is default AND has configDir) */}
|
||||
{!(profile.oauthToken || (profile.isDefault && profile.configDir)) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -420,6 +421,22 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
|
||||
)}
|
||||
Authenticate
|
||||
</Button>
|
||||
) : (
|
||||
/* Re-authenticate button for already authenticated profiles */
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleAuthenticateProfile(profile.id)}
|
||||
disabled={authenticatingProfileId === profile.id}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Re-authenticate profile"
|
||||
>
|
||||
{authenticatingProfileId === profile.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{profile.id !== activeProfileId && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Check, Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Label } from '../ui/label';
|
||||
import { COLOR_THEMES } from '../../../shared/constants';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { ColorTheme, AppSettings } from '../../../shared/types';
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
settings: AppSettings;
|
||||
onSettingsChange: (settings: AppSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme selector component displaying a grid of theme cards with preview swatches
|
||||
* and a 3-option mode toggle (Light/Dark/System)
|
||||
*
|
||||
* Theme changes are applied immediately for live preview, while other settings
|
||||
* require saving to take effect.
|
||||
*/
|
||||
export function ThemeSelector({ settings, onSettingsChange }: ThemeSelectorProps) {
|
||||
const updateStoreSettings = useSettingsStore((state) => state.updateSettings);
|
||||
|
||||
const currentColorTheme = settings.colorTheme || 'default';
|
||||
const currentMode = settings.theme;
|
||||
const isDark = currentMode === 'dark' ||
|
||||
(currentMode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
const handleColorThemeChange = (themeId: ColorTheme) => {
|
||||
// Update local draft state
|
||||
onSettingsChange({ ...settings, colorTheme: themeId });
|
||||
// Apply immediately to store for live preview (triggers App.tsx useEffect)
|
||||
updateStoreSettings({ colorTheme: themeId });
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'light' | 'dark' | 'system') => {
|
||||
// Update local draft state
|
||||
onSettingsChange({ ...settings, theme: mode });
|
||||
// Apply immediately to store for live preview (triggers App.tsx useEffect)
|
||||
updateStoreSettings({ theme: mode });
|
||||
};
|
||||
|
||||
const getModeIcon = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case 'dark':
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Mode Toggle */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground">Appearance Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">Choose light, dark, or system preference</p>
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md pt-1">
|
||||
{(['system', 'light', 'dark'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleModeChange(mode)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
currentMode === mode
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{getModeIcon(mode)}
|
||||
<span className="text-sm font-medium capitalize">{mode}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Grid */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground">Color Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">Select a color palette for the interface</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
|
||||
{COLOR_THEMES.map((theme) => {
|
||||
const isSelected = currentColorTheme === theme.id;
|
||||
const bgColor = isDark ? theme.previewColors.darkBg : theme.previewColors.bg;
|
||||
const accentColor = isDark
|
||||
? (theme.previewColors.darkAccent || theme.previewColors.accent)
|
||||
: theme.previewColors.accent;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => handleColorThemeChange(theme.id)}
|
||||
className={cn(
|
||||
'relative flex flex-col p-4 rounded-lg border-2 text-left transition-all',
|
||||
'hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/30'
|
||||
)}
|
||||
>
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview swatches */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex -space-x-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-background shadow-sm"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
title="Background color"
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-background shadow-sm"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
title="Accent color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm text-foreground">{theme.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">{theme.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Label } from '../ui/label';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import type { AppSettings } from '../../../shared/types';
|
||||
|
||||
interface ThemeSettingsProps {
|
||||
@@ -11,47 +9,15 @@ interface ThemeSettingsProps {
|
||||
|
||||
/**
|
||||
* Theme and appearance settings section
|
||||
* Wraps the ThemeSelector component with a consistent settings section layout
|
||||
*/
|
||||
export function ThemeSettings({ settings, onSettingsChange }: ThemeSettingsProps) {
|
||||
const getThemeIcon = (theme: string) => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case 'dark':
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Appearance"
|
||||
description="Customize how Auto Claude looks"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="theme" className="text-sm font-medium text-foreground">Theme</Label>
|
||||
<p className="text-sm text-muted-foreground">Choose your preferred color scheme</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => onSettingsChange({ ...settings, theme })}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all',
|
||||
settings.theme === theme
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{getThemeIcon(theme)}
|
||||
<span className="text-sm font-medium capitalize">{theme}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSelector settings={settings} onSettingsChange={onSettingsChange} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSettingsStore, saveSettings as saveSettingsToStore, loadSettings as loadSettingsFromStore } from '../../../stores/settings-store';
|
||||
import type { AppSettings } from '../../../../shared/types';
|
||||
|
||||
/**
|
||||
* Custom hook for managing application settings
|
||||
* Provides state management and save/load functionality
|
||||
*
|
||||
* Theme changes are applied immediately for live preview. If the user cancels
|
||||
* without saving, call revertTheme() to restore the original theme.
|
||||
*/
|
||||
export function useSettings() {
|
||||
const currentSettings = useSettingsStore((state) => state.settings);
|
||||
const updateStoreSettings = useSettingsStore((state) => state.updateSettings);
|
||||
const [settings, setSettings] = useState<AppSettings>(currentSettings);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Store the original theme settings when the hook mounts (dialog opens)
|
||||
// This allows us to revert if the user cancels
|
||||
const originalThemeRef = useRef<{ theme: AppSettings['theme']; colorTheme: AppSettings['colorTheme'] }>({
|
||||
theme: currentSettings.theme,
|
||||
colorTheme: currentSettings.colorTheme
|
||||
});
|
||||
|
||||
// Sync with store
|
||||
useEffect(() => {
|
||||
setSettings(currentSettings);
|
||||
}, [currentSettings]);
|
||||
|
||||
// Load settings on mount
|
||||
// Load settings on mount and capture original theme
|
||||
useEffect(() => {
|
||||
loadSettingsFromStore();
|
||||
// Update the original theme ref when settings load
|
||||
originalThemeRef.current = {
|
||||
theme: currentSettings.theme,
|
||||
colorTheme: currentSettings.colorTheme
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveSettings = async () => {
|
||||
@@ -63,6 +79,29 @@ export function useSettings() {
|
||||
setSettings((prev) => ({ ...prev, ...partial }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Revert theme to the original values (before any preview changes).
|
||||
* Call this when the user cancels the settings dialog without saving.
|
||||
*/
|
||||
const revertTheme = useCallback(() => {
|
||||
const original = originalThemeRef.current;
|
||||
updateStoreSettings({
|
||||
theme: original.theme,
|
||||
colorTheme: original.colorTheme
|
||||
});
|
||||
}, [updateStoreSettings]);
|
||||
|
||||
/**
|
||||
* Capture the current theme as the new "original" after successful save.
|
||||
* This updates the reference point for future reverts.
|
||||
*/
|
||||
const commitTheme = useCallback(() => {
|
||||
originalThemeRef.current = {
|
||||
theme: settings.theme,
|
||||
colorTheme: settings.colorTheme
|
||||
};
|
||||
}, [settings.theme, settings.colorTheme]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
setSettings,
|
||||
@@ -70,6 +109,8 @@ export function useSettings() {
|
||||
isSaving,
|
||||
error,
|
||||
saveSettings,
|
||||
applyTheme
|
||||
applyTheme,
|
||||
revertTheme,
|
||||
commitTheme
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
export { AppSettingsDialog, type AppSection } from './AppSettings';
|
||||
export { ThemeSettings } from './ThemeSettings';
|
||||
export { ThemeSelector } from './ThemeSelector';
|
||||
export { GeneralSettings } from './GeneralSettings';
|
||||
export { IntegrationSettings } from './IntegrationSettings';
|
||||
export { AdvancedSettings } from './AdvancedSettings';
|
||||
|
||||
+223
-3
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown } from 'lucide-react';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch } from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Switch } from '../../ui/switch';
|
||||
@@ -34,6 +34,7 @@ interface GitHubIntegrationProps {
|
||||
setShowGitHubToken: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
gitHubConnectionStatus: GitHubSyncStatus | null;
|
||||
isCheckingGitHub: boolean;
|
||||
projectPath?: string; // Project path for fetching git branches
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,8 @@ export function GitHubIntegration({
|
||||
showGitHubToken: _showGitHubToken,
|
||||
setShowGitHubToken: _setShowGitHubToken,
|
||||
gitHubConnectionStatus,
|
||||
isCheckingGitHub
|
||||
isCheckingGitHub,
|
||||
projectPath
|
||||
}: GitHubIntegrationProps) {
|
||||
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
|
||||
const [oauthUsername, setOauthUsername] = useState<string | null>(null);
|
||||
@@ -54,8 +56,14 @@ export function GitHubIntegration({
|
||||
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
|
||||
const [reposError, setReposError] = useState<string | null>(null);
|
||||
|
||||
// Branch selection state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
|
||||
debugLog('Render - authMode:', authMode);
|
||||
debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken } : null);
|
||||
debugLog('Render - projectPath:', projectPath);
|
||||
debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken, defaultBranch: envConfig.defaultBranch } : null);
|
||||
|
||||
// Fetch repos when entering oauth-success mode
|
||||
useEffect(() => {
|
||||
@@ -64,6 +72,60 @@ export function GitHubIntegration({
|
||||
}
|
||||
}, [authMode]);
|
||||
|
||||
// Fetch branches when GitHub is enabled and project path is available
|
||||
useEffect(() => {
|
||||
debugLog(`useEffect[branches] - githubEnabled: ${envConfig?.githubEnabled}, projectPath: ${projectPath}`);
|
||||
if (envConfig?.githubEnabled && projectPath) {
|
||||
debugLog('useEffect[branches] - Triggering fetchBranches');
|
||||
fetchBranches();
|
||||
} else {
|
||||
debugLog('useEffect[branches] - Skipping fetchBranches (conditions not met)');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [envConfig?.githubEnabled, projectPath]);
|
||||
|
||||
const fetchBranches = async () => {
|
||||
if (!projectPath) {
|
||||
debugLog('fetchBranches: No projectPath, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('fetchBranches: Starting with projectPath:', projectPath);
|
||||
setIsLoadingBranches(true);
|
||||
setBranchesError(null);
|
||||
|
||||
try {
|
||||
debugLog('fetchBranches: Calling getGitBranches...');
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
debugLog('fetchBranches: getGitBranches result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error });
|
||||
|
||||
// result.data is the array directly (not { branches: [] })
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
debugLog('fetchBranches: Loaded branches:', result.data.length);
|
||||
|
||||
// Auto-detect default branch if not set
|
||||
if (!envConfig?.defaultBranch) {
|
||||
debugLog('fetchBranches: No defaultBranch set, auto-detecting...');
|
||||
const detectResult = await window.electronAPI.detectMainBranch(projectPath);
|
||||
debugLog('fetchBranches: detectMainBranch result:', detectResult);
|
||||
if (detectResult.success && detectResult.data) {
|
||||
debugLog('fetchBranches: Auto-detected default branch:', detectResult.data);
|
||||
updateEnvConfig({ defaultBranch: detectResult.data });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugLog('fetchBranches: Failed -', result.error || 'No data returned');
|
||||
setBranchesError(result.error || 'Failed to load branches');
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog('fetchBranches: Exception:', err);
|
||||
setBranchesError(err instanceof Error ? err.message : 'Failed to load branches');
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserRepos = async () => {
|
||||
debugLog('Fetching user repositories...');
|
||||
setIsLoadingRepos(true);
|
||||
@@ -248,6 +310,20 @@ export function GitHubIntegration({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Default Branch Selector */}
|
||||
{projectPath && (
|
||||
<BranchSelector
|
||||
branches={branches}
|
||||
selectedBranch={envConfig.defaultBranch || ''}
|
||||
isLoading={isLoadingBranches}
|
||||
error={branchesError}
|
||||
onSelect={(branch) => updateEnvConfig({ defaultBranch: branch })}
|
||||
onRefresh={fetchBranches}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<AutoSyncToggle
|
||||
enabled={envConfig.githubAutoSync || false}
|
||||
onToggle={(checked) => updateEnvConfig({ githubAutoSync: checked })}
|
||||
@@ -500,3 +576,147 @@ function AutoSyncToggle({ enabled, onToggle }: AutoSyncToggleProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BranchSelectorProps {
|
||||
branches: string[];
|
||||
selectedBranch: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onSelect: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function BranchSelector({
|
||||
branches,
|
||||
selectedBranch,
|
||||
isLoading,
|
||||
error,
|
||||
onSelect,
|
||||
onRefresh
|
||||
}: BranchSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredBranches = branches.filter(branch =>
|
||||
branch.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-info" />
|
||||
<Label className="text-sm font-medium text-foreground">Default Branch</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
Base branch for creating task worktrees
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative pl-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading branches...
|
||||
</span>
|
||||
) : selectedBranch ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
{selectedBranch}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Auto-detect (main/master)</span>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && !isLoading && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
|
||||
{/* Search filter */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder="Search branches..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-detect option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect('');
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
!selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground italic">Auto-detect (main/master)</span>
|
||||
</button>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="max-h-40 overflow-y-auto border-t border-border">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
{filter ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(branch);
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
branch === selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{branch}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
All new tasks will branch from <code className="px-1 bg-muted rounded">{selectedBranch}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ export function SectionRouter({
|
||||
setShowGitHubToken={setShowGitHubToken}
|
||||
gitHubConnectionStatus={gitHubConnectionStatus}
|
||||
isCheckingGitHub={isCheckingGitHub}
|
||||
projectPath={project.path}
|
||||
/>
|
||||
</InitializationGuard>
|
||||
</SettingsSection>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user