Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 2ca89ce7c9 | |||
| e134c4cba9 | |||
| 81e1536801 | |||
| 1a7cf409eb | |||
| 5f26d3964d | |||
| 4a4ad6b1df | |||
| 6a4c1b452b | |||
| b75a09c88c | |||
| a107ed03a3 | |||
| 679b8cd948 | |||
| 1a38a06e6e | |||
| fe691066dd |
@@ -1,3 +1,58 @@
|
||||
## 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
|
||||
|
||||
@@ -118,7 +118,7 @@ See [RELEASE.md](RELEASE.md) for detailed release process documentation.
|
||||
- **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
|
||||
|
||||
@@ -192,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.
|
||||
|
||||
|
||||
Generated
+29
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.0",
|
||||
"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.6",
|
||||
"version": "2.6.0",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Auto Claude Team",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -69,6 +69,41 @@ export function registerEnvHandlers(
|
||||
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;
|
||||
}
|
||||
@@ -127,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='}
|
||||
@@ -262,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 };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,64 @@ 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"
|
||||
const DEVICE_CODE_PATTERN = /(?:one-time code|code):\s*([A-Z0-9]{4}-[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
|
||||
*/
|
||||
function parseDeviceCode(output: string): string | null {
|
||||
const match = output.match(DEVICE_CODE_PATTERN);
|
||||
if (match && match[1]) {
|
||||
debugLog('Parsed device code:', match[1]);
|
||||
return match[1];
|
||||
}
|
||||
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 +172,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 +210,60 @@ 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:', extractedDeviceCode);
|
||||
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
|
||||
}
|
||||
} 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 +272,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 +313,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.'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -275,7 +275,7 @@ export function registerRoadmapHandlers(
|
||||
async (
|
||||
_,
|
||||
projectId: string,
|
||||
features: RoadmapFeature[]
|
||||
roadmapData: Roadmap
|
||||
): Promise<IPCResult> => {
|
||||
const project = projectStore.getProject(projectId);
|
||||
if (!project) {
|
||||
@@ -294,10 +294,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 +315,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) {
|
||||
|
||||
@@ -94,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 {
|
||||
@@ -105,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();
|
||||
@@ -113,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 };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -49,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';
|
||||
}
|
||||
@@ -145,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';
|
||||
}
|
||||
@@ -500,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 {
|
||||
@@ -531,7 +546,8 @@ export function registerWorktreeHandlers(
|
||||
success: true,
|
||||
message,
|
||||
staged,
|
||||
projectPath: staged ? project.path : undefined
|
||||
projectPath: staged ? project.path : undefined,
|
||||
suggestedCommitMessage
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -865,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';
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 } from '../../shared/utils/shell-escape';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
|
||||
|
||||
/**
|
||||
@@ -327,13 +327,20 @@ 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
|
||||
// SECURITY: Use escapeShellArg to prevent command injection via configDir
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && 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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -177,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
|
||||
@@ -206,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(() => {
|
||||
@@ -444,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">
|
||||
@@ -469,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 && (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -102,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
|
||||
@@ -143,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);
|
||||
@@ -159,15 +160,15 @@ 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(() => {
|
||||
@@ -542,12 +543,12 @@ 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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 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,6 +3,7 @@ import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { AgentProfileSettings } from './AgentProfileSettings';
|
||||
import { AVAILABLE_MODELS } from '../../../shared/constants';
|
||||
import type { AppSettings } from '../../../shared/types';
|
||||
|
||||
@@ -18,64 +19,51 @@ 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-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 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>
|
||||
<Switch
|
||||
id="autoNameTerminals"
|
||||
checked={settings.autoNameTerminals}
|
||||
onCheckedChange={(checked) => onSettingsChange({ ...settings, autoNameTerminals: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Progress } from '../ui/progress';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '../ui/alert-dialog';
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Pencil,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { calculateProgress } from '../../lib/utils';
|
||||
import { startTask, stopTask, submitReview, recoverStuckTask, deleteTask } from '../../stores/task-store';
|
||||
import { TASK_STATUS_LABELS } from '../../../shared/constants';
|
||||
import { TaskEditDialog } from '../TaskEditDialog';
|
||||
import { useTaskDetail } from './hooks/useTaskDetail';
|
||||
import { TaskMetadata } from './TaskMetadata';
|
||||
import { TaskWarnings } from './TaskWarnings';
|
||||
import { TaskSubtasks } from './TaskSubtasks';
|
||||
import { TaskLogs } from './TaskLogs';
|
||||
import { TaskReview } from './TaskReview';
|
||||
import type { Task } from '../../../shared/types';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
open: boolean;
|
||||
task: Task | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailModal({ open, task, onOpenChange }: TaskDetailModalProps) {
|
||||
// Don't render anything if no task
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskDetailModalContent
|
||||
open={open}
|
||||
task={task}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Separate component to use hooks only when task exists
|
||||
function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void }) {
|
||||
const state = useTaskDetail({ task });
|
||||
const progressPercent = calculateProgress(task.subtasks);
|
||||
const completedSubtasks = task.subtasks.filter(s => s.status === 'completed').length;
|
||||
const totalSubtasks = task.subtasks.length;
|
||||
|
||||
// Event Handlers
|
||||
const handleStartStop = () => {
|
||||
if (state.isRunning && !state.isStuck) {
|
||||
stopTask(task.id);
|
||||
} else {
|
||||
startTask(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecover = async () => {
|
||||
state.setIsRecovering(true);
|
||||
const result = await recoverStuckTask(task.id, { autoRestart: true });
|
||||
if (result.success) {
|
||||
state.setIsStuck(false);
|
||||
state.setHasCheckedRunning(false);
|
||||
}
|
||||
state.setIsRecovering(false);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!state.feedback.trim()) {
|
||||
return;
|
||||
}
|
||||
state.setIsSubmitting(true);
|
||||
await submitReview(task.id, false, state.feedback);
|
||||
state.setIsSubmitting(false);
|
||||
state.setFeedback('');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
state.setIsDeleting(true);
|
||||
state.setDeleteError(null);
|
||||
const result = await deleteTask(task.id);
|
||||
if (result.success) {
|
||||
state.setShowDeleteDialog(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
state.setDeleteError(result.error || 'Failed to delete task');
|
||||
}
|
||||
state.setIsDeleting(false);
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
state.setIsMerging(true);
|
||||
state.setWorkspaceError(null);
|
||||
try {
|
||||
const result = await window.electronAPI.mergeWorktree(task.id, { noCommit: state.stageOnly });
|
||||
if (result.success && result.data?.success) {
|
||||
if (state.stageOnly && result.data.staged) {
|
||||
state.setWorkspaceError(null);
|
||||
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
|
||||
state.setStagedProjectPath(result.data.projectPath);
|
||||
state.setSuggestedCommitMessage(result.data.suggestedCommitMessage);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
state.setWorkspaceError(result.data?.message || result.error || 'Failed to merge changes');
|
||||
}
|
||||
} catch (error) {
|
||||
state.setWorkspaceError(error instanceof Error ? error.message : 'Unknown error during merge');
|
||||
} finally {
|
||||
state.setIsMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = async () => {
|
||||
state.setIsDiscarding(true);
|
||||
state.setWorkspaceError(null);
|
||||
const result = await window.electronAPI.discardWorktree(task.id);
|
||||
if (result.success && result.data?.success) {
|
||||
state.setShowDiscardDialog(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
state.setWorkspaceError(result.data?.message || result.error || 'Failed to discard changes');
|
||||
}
|
||||
state.setIsDiscarding(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Render primary action button based on state
|
||||
const renderPrimaryAction = () => {
|
||||
if (state.isStuck) {
|
||||
return (
|
||||
<Button
|
||||
variant="warning"
|
||||
onClick={handleRecover}
|
||||
disabled={state.isRecovering}
|
||||
>
|
||||
{state.isRecovering ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Recovering...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Recover Task
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.isIncomplete) {
|
||||
return (
|
||||
<Button variant="default" onClick={handleStartStop}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Resume Task
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'backlog' || task.status === 'in_progress') {
|
||||
return (
|
||||
<Button
|
||||
variant={state.isRunning ? 'destructive' : 'default'}
|
||||
onClick={handleStartStop}
|
||||
>
|
||||
{state.isRunning ? (
|
||||
<>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Stop Task
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Task
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'done') {
|
||||
return (
|
||||
<div className="completion-state text-sm flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="font-medium">Task completed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Semi-transparent overlay - can see background content */}
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Full-height centered modal content */}
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-[50%] top-4 z-50',
|
||||
'translate-x-[-50%]',
|
||||
'w-[95vw] max-w-5xl h-[calc(100vh-32px)]',
|
||||
'bg-card border border-border rounded-xl',
|
||||
'shadow-2xl overflow-hidden flex flex-col',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogPrimitive.Title className="text-xl font-semibold leading-tight text-foreground pr-4">
|
||||
{task.title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description asChild>
|
||||
<div className="mt-2.5 flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{task.specId}
|
||||
</Badge>
|
||||
{state.isStuck ? (
|
||||
<Badge variant="warning" className="text-xs flex items-center gap-1 animate-pulse">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Stuck
|
||||
</Badge>
|
||||
) : state.isIncomplete ? (
|
||||
<>
|
||||
<Badge variant="warning" className="text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Incomplete
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Badge
|
||||
variant={task.status === 'done' ? 'success' : task.status === 'human_review' ? 'purple' : task.status === 'in_progress' ? 'info' : 'secondary'}
|
||||
className={cn('text-xs', (task.status === 'in_progress' && !state.isStuck) && 'status-running')}
|
||||
>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
</Badge>
|
||||
{task.status === 'human_review' && task.reviewReason && (
|
||||
<Badge
|
||||
variant={task.reviewReason === 'completed' ? 'success' : task.reviewReason === 'errors' ? 'destructive' : 'warning'}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.reviewReason === 'completed' ? 'Completed' :
|
||||
task.reviewReason === 'errors' ? 'Has Errors' :
|
||||
task.reviewReason === 'plan_review' ? 'Approve Plan' : 'QA Issues'}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Compact progress indicator */}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{completedSubtasks}/{totalSubtasks} subtasks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-primary/10 hover:text-primary transition-colors"
|
||||
onClick={() => state.setIsEditDialogOpen(true)}
|
||||
disabled={state.isRunning && !state.isStuck}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar - only show when running or has progress */}
|
||||
{(state.isRunning || completedSubtasks > 0) && totalSubtasks > 0 && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Progress value={progressPercent} className="h-1.5 flex-1" />
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-10 text-right">{progressPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings - compact inline */}
|
||||
{(state.isStuck || state.isIncomplete) && (
|
||||
<div className="mt-3">
|
||||
<TaskWarnings
|
||||
isStuck={state.isStuck}
|
||||
isIncomplete={state.isIncomplete}
|
||||
isRecovering={state.isRecovering}
|
||||
taskProgress={state.taskProgress}
|
||||
onRecover={handleRecover}
|
||||
onResume={handleStartStop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body - Single Column with Tabs */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<Tabs value={state.activeTab} onValueChange={state.setActiveTab} className="flex flex-col h-full">
|
||||
<TabsList className="w-full justify-start rounded-none border-b border-border bg-transparent px-5 h-auto shrink-0">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-sm"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subtasks"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-sm"
|
||||
>
|
||||
Subtasks ({task.subtasks.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logs"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5 text-sm"
|
||||
>
|
||||
Logs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="flex-1 min-h-0 overflow-hidden mt-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Metadata */}
|
||||
<TaskMetadata task={task} />
|
||||
|
||||
{/* Human Review Section */}
|
||||
{state.needsReview && (
|
||||
<>
|
||||
<Separator />
|
||||
<TaskReview
|
||||
task={task}
|
||||
feedback={state.feedback}
|
||||
isSubmitting={state.isSubmitting}
|
||||
worktreeStatus={state.worktreeStatus}
|
||||
worktreeDiff={state.worktreeDiff}
|
||||
isLoadingWorktree={state.isLoadingWorktree}
|
||||
isMerging={state.isMerging}
|
||||
isDiscarding={state.isDiscarding}
|
||||
showDiscardDialog={state.showDiscardDialog}
|
||||
showDiffDialog={state.showDiffDialog}
|
||||
workspaceError={state.workspaceError}
|
||||
stageOnly={state.stageOnly}
|
||||
stagedSuccess={state.stagedSuccess}
|
||||
stagedProjectPath={state.stagedProjectPath}
|
||||
suggestedCommitMessage={state.suggestedCommitMessage}
|
||||
mergePreview={state.mergePreview}
|
||||
isLoadingPreview={state.isLoadingPreview}
|
||||
showConflictDialog={state.showConflictDialog}
|
||||
onFeedbackChange={state.setFeedback}
|
||||
onReject={handleReject}
|
||||
onMerge={handleMerge}
|
||||
onDiscard={handleDiscard}
|
||||
onShowDiscardDialog={state.setShowDiscardDialog}
|
||||
onShowDiffDialog={state.setShowDiffDialog}
|
||||
onStageOnlyChange={state.setStageOnly}
|
||||
onShowConflictDialog={state.setShowConflictDialog}
|
||||
onLoadMergePreview={state.loadMergePreview}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Subtasks Tab */}
|
||||
<TabsContent value="subtasks" className="flex-1 min-h-0 overflow-hidden mt-0">
|
||||
<TaskSubtasks task={task} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Logs Tab */}
|
||||
<TabsContent value="logs" className="flex-1 min-h-0 overflow-hidden mt-0">
|
||||
<TaskLogs
|
||||
task={task}
|
||||
phaseLogs={state.phaseLogs}
|
||||
isLoadingLogs={state.isLoadingLogs}
|
||||
expandedPhases={state.expandedPhases}
|
||||
isStuck={state.isStuck}
|
||||
logsEndRef={state.logsEndRef}
|
||||
logsContainerRef={state.logsContainerRef}
|
||||
onLogsScroll={state.handleLogsScroll}
|
||||
onTogglePhase={state.togglePhase}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Footer - Actions */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-t border-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => state.setShowDeleteDialog(true)}
|
||||
disabled={state.isRunning && !state.isStuck}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Task
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{renderPrimaryAction()}
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
|
||||
{/* Edit Task Dialog */}
|
||||
<TaskEditDialog
|
||||
task={task}
|
||||
open={state.isEditDialogOpen}
|
||||
onOpenChange={state.setIsEditDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={state.showDeleteDialog} onOpenChange={state.setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Delete Task
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-sm text-muted-foreground space-y-3">
|
||||
<p>
|
||||
Are you sure you want to delete <strong className="text-foreground">"{task.title}"</strong>?
|
||||
</p>
|
||||
<p className="text-destructive">
|
||||
This action cannot be undone. All task files, including the spec, implementation plan, and any generated code will be permanently deleted from the project.
|
||||
</p>
|
||||
{state.deleteError && (
|
||||
<p className="text-destructive bg-destructive/10 px-3 py-2 rounded-lg text-sm">
|
||||
{state.deleteError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={state.isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={state.isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{state.isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Permanently
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
|
||||
state.setWorkspaceError(null);
|
||||
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
|
||||
state.setStagedProjectPath(result.data.projectPath);
|
||||
state.setSuggestedCommitMessage(result.data.suggestedCommitMessage);
|
||||
} else {
|
||||
console.warn('[TaskDetailPanel] Full merge success, closing panel');
|
||||
onClose();
|
||||
@@ -196,6 +197,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
|
||||
stageOnly={state.stageOnly}
|
||||
stagedSuccess={state.stagedSuccess}
|
||||
stagedProjectPath={state.stagedProjectPath}
|
||||
suggestedCommitMessage={state.suggestedCommitMessage}
|
||||
mergePreview={state.mergePreview}
|
||||
isLoadingPreview={state.isLoadingPreview}
|
||||
showConflictDialog={state.showConflictDialog}
|
||||
|
||||
@@ -337,7 +337,7 @@ function LogEntry({ entry }: LogEntryProps) {
|
||||
<Icon className="h-3 w-3 animate-pulse" />
|
||||
<span className="font-medium">{label}</span>
|
||||
{entry.tool_input && (
|
||||
<span className="text-muted-foreground truncate max-w-[200px]" title={entry.tool_input}>
|
||||
<span className="text-muted-foreground truncate max-w-[500px]" title={entry.tool_input}>
|
||||
{entry.tool_input}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Info,
|
||||
Target,
|
||||
Bug,
|
||||
Wrench,
|
||||
@@ -47,18 +46,24 @@ interface TaskMetadataProps {
|
||||
}
|
||||
|
||||
export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
const hasClassification = task.metadata && (
|
||||
task.metadata.category ||
|
||||
task.metadata.priority ||
|
||||
task.metadata.complexity ||
|
||||
task.metadata.impact ||
|
||||
task.metadata.securitySeverity ||
|
||||
task.metadata.sourceType
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Classification Badges */}
|
||||
{task.metadata && (
|
||||
<div>
|
||||
<div className="section-divider mb-3">
|
||||
<Info className="h-3 w-3" />
|
||||
Classification
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Compact Metadata Bar: Classification + Timeline */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-4 border-b border-border">
|
||||
{/* Classification Badges - Left */}
|
||||
{hasClassification && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Category */}
|
||||
{task.metadata.category && (
|
||||
{task.metadata?.category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', TASK_CATEGORY_COLORS[task.metadata.category])}
|
||||
@@ -71,7 +76,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{/* Priority */}
|
||||
{task.metadata.priority && (
|
||||
{task.metadata?.priority && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', TASK_PRIORITY_COLORS[task.metadata.priority])}
|
||||
@@ -80,7 +85,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{/* Complexity */}
|
||||
{task.metadata.complexity && (
|
||||
{task.metadata?.complexity && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', TASK_COMPLEXITY_COLORS[task.metadata.complexity])}
|
||||
@@ -89,7 +94,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{/* Impact */}
|
||||
{task.metadata.impact && (
|
||||
{task.metadata?.impact && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', TASK_IMPACT_COLORS[task.metadata.impact])}
|
||||
@@ -98,17 +103,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{/* Security Severity */}
|
||||
{task.metadata.securitySeverity && (
|
||||
{task.metadata?.securitySeverity && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', TASK_IMPACT_COLORS[task.metadata.securitySeverity])}
|
||||
>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{task.metadata.securitySeverity} severity
|
||||
{task.metadata.securitySeverity}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Source Type */}
|
||||
{task.metadata.sourceType && (
|
||||
{task.metadata?.sourceType && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{task.metadata.sourceType === 'ideation' && task.metadata.ideationType
|
||||
? IDEATION_TYPE_LABELS[task.metadata.ideationType] || task.metadata.ideationType
|
||||
@@ -116,66 +121,72 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{/* Timeline - Right */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
Created {formatRelativeTime(task.createdAt)}
|
||||
</span>
|
||||
<span className="text-border">•</span>
|
||||
<span>Updated {formatRelativeTime(task.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description - Primary Content */}
|
||||
{task.description && (
|
||||
<div>
|
||||
<div className="section-divider mb-3">
|
||||
<FileCode className="h-3 w-3" />
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{sanitizeMarkdownForDisplay(task.description, 500)}
|
||||
<p className="text-sm text-foreground/90 leading-relaxed">
|
||||
{sanitizeMarkdownForDisplay(task.description, 800)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Details */}
|
||||
{/* Secondary Details */}
|
||||
{task.metadata && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 pt-2">
|
||||
{/* Rationale */}
|
||||
{task.metadata.rationale && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3.5 w-3.5 text-warning" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3 w-3 text-warning" />
|
||||
Rationale
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{task.metadata.rationale}</p>
|
||||
<p className="text-sm text-foreground/80">{task.metadata.rationale}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Problem Solved */}
|
||||
{task.metadata.problemSolved && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<Target className="h-3.5 w-3.5 text-success" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<Target className="h-3 w-3 text-success" />
|
||||
Problem Solved
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{task.metadata.problemSolved}</p>
|
||||
<p className="text-sm text-foreground/80">{task.metadata.problemSolved}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Audience */}
|
||||
{task.metadata.targetAudience && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5 text-info" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3 text-info" />
|
||||
Target Audience
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{task.metadata.targetAudience}</p>
|
||||
<p className="text-sm text-foreground/80">{task.metadata.targetAudience}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{task.metadata.dependencies && task.metadata.dependencies.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5 text-purple-400" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<GitBranch className="h-3 w-3 text-purple-400" />
|
||||
Dependencies
|
||||
</h3>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-0.5">
|
||||
<ul className="text-sm text-foreground/80 list-disc list-inside space-y-0.5">
|
||||
{task.metadata.dependencies.map((dep, idx) => (
|
||||
<li key={idx}>{dep}</li>
|
||||
))}
|
||||
@@ -186,11 +197,11 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
{/* Acceptance Criteria */}
|
||||
{task.metadata.acceptanceCriteria && task.metadata.acceptanceCriteria.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<ListChecks className="h-3.5 w-3.5 text-success" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<ListChecks className="h-3 w-3 text-success" />
|
||||
Acceptance Criteria
|
||||
</h3>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-0.5">
|
||||
<ul className="text-sm text-foreground/80 list-disc list-inside space-y-0.5">
|
||||
{task.metadata.acceptanceCriteria.map((criteria, idx) => (
|
||||
<li key={idx}>{criteria}</li>
|
||||
))}
|
||||
@@ -201,8 +212,8 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
{/* Affected Files */}
|
||||
{task.metadata.affectedFiles && task.metadata.affectedFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<FileCode className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<FileCode className="h-3 w-3" />
|
||||
Affected Files
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -223,24 +234,6 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div>
|
||||
<div className="section-divider mb-3">
|
||||
<Clock className="h-3 w-3" />
|
||||
Timeline
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span className="text-foreground tabular-nums">{formatRelativeTime(task.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Updated</span>
|
||||
<span className="text-foreground tabular-nums">{formatRelativeTime(task.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface TaskReviewProps {
|
||||
stageOnly: boolean;
|
||||
stagedSuccess: string | null;
|
||||
stagedProjectPath: string | undefined;
|
||||
suggestedCommitMessage: string | undefined;
|
||||
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null;
|
||||
isLoadingPreview: boolean;
|
||||
showConflictDialog: boolean;
|
||||
@@ -38,6 +39,7 @@ interface TaskReviewProps {
|
||||
onStageOnlyChange: (value: boolean) => void;
|
||||
onShowConflictDialog: (show: boolean) => void;
|
||||
onLoadMergePreview: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +66,7 @@ export function TaskReview({
|
||||
stageOnly,
|
||||
stagedSuccess,
|
||||
stagedProjectPath,
|
||||
suggestedCommitMessage,
|
||||
mergePreview,
|
||||
isLoadingPreview,
|
||||
showConflictDialog,
|
||||
@@ -75,7 +78,8 @@ export function TaskReview({
|
||||
onShowDiffDialog,
|
||||
onStageOnlyChange,
|
||||
onShowConflictDialog,
|
||||
onLoadMergePreview
|
||||
onLoadMergePreview,
|
||||
onClose
|
||||
}: TaskReviewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -88,6 +92,7 @@ export function TaskReview({
|
||||
stagedSuccess={stagedSuccess}
|
||||
stagedProjectPath={stagedProjectPath}
|
||||
task={task}
|
||||
suggestedCommitMessage={suggestedCommitMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -116,9 +121,10 @@ export function TaskReview({
|
||||
task={task}
|
||||
projectPath={stagedProjectPath}
|
||||
hasWorktree={worktreeStatus?.exists || false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<NoWorkspaceMessage task={task} />
|
||||
<NoWorkspaceMessage task={task} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{/* QA Feedback Section */}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
const [stageOnly, setStageOnly] = useState(task.status === 'human_review');
|
||||
const [stagedSuccess, setStagedSuccess] = useState<string | null>(null);
|
||||
const [stagedProjectPath, setStagedProjectPath] = useState<string | undefined>(undefined);
|
||||
const [suggestedCommitMessage, setSuggestedCommitMessage] = useState<string | undefined>(undefined);
|
||||
const [phaseLogs, setPhaseLogs] = useState<TaskLogs | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [expandedPhases, setExpandedPhases] = useState<Set<TaskLogPhase>>(new Set());
|
||||
@@ -279,6 +280,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
stageOnly,
|
||||
stagedSuccess,
|
||||
stagedProjectPath,
|
||||
suggestedCommitMessage,
|
||||
phaseLogs,
|
||||
isLoadingLogs,
|
||||
expandedPhases,
|
||||
@@ -318,6 +320,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
|
||||
setStageOnly,
|
||||
setStagedSuccess,
|
||||
setStagedProjectPath,
|
||||
setSuggestedCommitMessage,
|
||||
setPhaseLogs,
|
||||
setIsLoadingLogs,
|
||||
setExpandedPhases,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { TaskDetailPanel } from './TaskDetailPanel';
|
||||
export { TaskDetailModal } from './TaskDetailModal';
|
||||
export { TaskHeader } from './TaskHeader';
|
||||
export { TaskProgress } from './TaskProgress';
|
||||
export { TaskMetadata } from './TaskMetadata';
|
||||
|
||||
+61
-2
@@ -1,11 +1,14 @@
|
||||
import { GitMerge, ExternalLink } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import type { Task } from '../../../../shared/types';
|
||||
|
||||
interface StagedSuccessMessageProps {
|
||||
stagedSuccess: string;
|
||||
stagedProjectPath: string | undefined;
|
||||
task: Task;
|
||||
suggestedCommitMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,8 +17,23 @@ interface StagedSuccessMessageProps {
|
||||
export function StagedSuccessMessage({
|
||||
stagedSuccess,
|
||||
stagedProjectPath,
|
||||
task
|
||||
task,
|
||||
suggestedCommitMessage
|
||||
}: StagedSuccessMessageProps) {
|
||||
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!commitMessage) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(commitMessage);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-success/30 bg-success/10 p-4">
|
||||
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
|
||||
@@ -25,6 +43,47 @@ export function StagedSuccessMessage({
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{stagedSuccess}
|
||||
</p>
|
||||
|
||||
{/* Commit Message Section */}
|
||||
{suggestedCommitMessage && (
|
||||
<div className="bg-background/50 rounded-lg p-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3 w-3 text-purple-400" />
|
||||
AI-generated commit message
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={!commitMessage}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1 text-success" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
className="font-mono text-xs min-h-[100px] bg-background/80 resize-y"
|
||||
placeholder="Commit message..."
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">
|
||||
Edit as needed, then copy and use with <code className="bg-background px-1 rounded">git commit -m "..."</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-background/50 rounded-lg p-3 mb-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Next steps:</p>
|
||||
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
|
||||
|
||||
+8
-3
@@ -24,12 +24,13 @@ export function LoadingMessage({ message = 'Loading workspace info...' }: Loadin
|
||||
|
||||
interface NoWorkspaceMessageProps {
|
||||
task?: Task;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays message when no workspace is found for the task
|
||||
*/
|
||||
export function NoWorkspaceMessage({ task }: NoWorkspaceMessageProps) {
|
||||
export function NoWorkspaceMessage({ task, onClose }: NoWorkspaceMessageProps) {
|
||||
const [isMarkingDone, setIsMarkingDone] = useState(false);
|
||||
|
||||
const handleMarkDone = async () => {
|
||||
@@ -38,6 +39,8 @@ export function NoWorkspaceMessage({ task }: NoWorkspaceMessageProps) {
|
||||
setIsMarkingDone(true);
|
||||
try {
|
||||
await persistTaskStatus(task.id, 'done');
|
||||
// Auto-close modal after marking as done
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
console.error('Error marking task as done:', err);
|
||||
} finally {
|
||||
@@ -85,12 +88,13 @@ interface StagedInProjectMessageProps {
|
||||
task: Task;
|
||||
projectPath?: string;
|
||||
hasWorktree?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays message when changes have already been staged in the main project
|
||||
*/
|
||||
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false }: StagedInProjectMessageProps) {
|
||||
export function StagedInProjectMessage({ task, projectPath, hasWorktree = false, onClose }: StagedInProjectMessageProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -110,7 +114,8 @@ export function StagedInProjectMessage({ task, projectPath, hasWorktree = false
|
||||
// Mark task as done
|
||||
await persistTaskStatus(task.id, 'done');
|
||||
|
||||
// Success - the UI will update automatically via the store
|
||||
// Auto-close modal after marking as done
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
console.error('Error deleting worktree:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete worktree');
|
||||
|
||||
+236
-172
@@ -9,10 +9,14 @@ import {
|
||||
FolderX,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
GitCommit,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { MergePreviewSummary } from './MergePreviewSummary';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { Task, WorktreeStatus, MergeConflict, MergeStats, GitConflictInfo } from '../../../../shared/types';
|
||||
|
||||
interface WorkspaceStatusProps {
|
||||
@@ -54,198 +58,258 @@ export function WorkspaceStatus({
|
||||
const hasGitConflicts = mergePreview?.gitConflicts?.hasConflicts;
|
||||
const hasUncommittedChanges = mergePreview?.uncommittedChanges?.hasChanges;
|
||||
const uncommittedCount = mergePreview?.uncommittedChanges?.count || 0;
|
||||
const hasAIConflicts = mergePreview && mergePreview.conflicts.length > 0;
|
||||
|
||||
// Determine overall status
|
||||
const statusColor = hasGitConflicts
|
||||
? 'warning'
|
||||
: hasUncommittedChanges
|
||||
? 'warning'
|
||||
: mergePreview && !hasAIConflicts
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
return (
|
||||
<div className="review-section-highlight">
|
||||
<h3 className="font-medium text-sm text-foreground mb-3 flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-400" />
|
||||
Build Ready for Review
|
||||
</h3>
|
||||
|
||||
{/* Change Summary */}
|
||||
<div className="bg-background/50 rounded-lg p-3 mb-3">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Files changed:</span>
|
||||
<span className="text-foreground font-medium">{worktreeStatus.filesChanged || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Commits:</span>
|
||||
<span className="text-foreground font-medium">{worktreeStatus.commitCount || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-success" />
|
||||
<span className="text-muted-foreground">Additions:</span>
|
||||
<span className="text-success font-medium">+{worktreeStatus.additions || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Minus className="h-4 w-4 text-destructive" />
|
||||
<span className="text-muted-foreground">Deletions:</span>
|
||||
<span className="text-destructive font-medium">-{worktreeStatus.deletions || 0}</span>
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden">
|
||||
{/* Header with stats */}
|
||||
<div className="px-4 py-3 bg-muted/30 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm text-foreground flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-400" />
|
||||
Build Ready for Review
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onShowDiffDialog(true)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
{worktreeStatus.worktreePath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `open-${task.id}`,
|
||||
cwd: worktreeStatus.worktreePath!
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2"
|
||||
title="Open in terminal"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact stats row */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileCode className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">{worktreeStatus.filesChanged || 0}</span> files
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitCommit className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-foreground">{worktreeStatus.commitCount || 0}</span> commits
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{worktreeStatus.additions || 0}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{worktreeStatus.deletions || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Branch info */}
|
||||
{worktreeStatus.branch && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50 text-xs text-muted-foreground">
|
||||
Branch: <code className="bg-background px-1 rounded">{worktreeStatus.branch}</code>
|
||||
{' → '}
|
||||
<code className="bg-background px-1 rounded">{worktreeStatus.baseBranch || 'main'}</code>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.branch}</code>
|
||||
<span className="mx-1.5">→</span>
|
||||
<code className="bg-background/80 px-1.5 py-0.5 rounded text-[11px]">{worktreeStatus.baseBranch || 'main'}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace Error */}
|
||||
{workspaceError && (
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-destructive">{workspaceError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Status/Warnings Section */}
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{/* Workspace Error */}
|
||||
{workspaceError && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-destructive">{workspaceError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uncommitted Changes Warning */}
|
||||
{hasUncommittedChanges && (
|
||||
<div className="bg-warning/10 border border-warning/30 rounded-lg p-3 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Uncommitted Changes Warning */}
|
||||
{hasUncommittedChanges && (
|
||||
<div className="flex items-start gap-2 p-2.5 rounded-lg bg-warning/10 border border-warning/20">
|
||||
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-warning">
|
||||
Uncommitted Changes Detected
|
||||
{uncommittedCount} uncommitted {uncommittedCount === 1 ? 'change' : 'changes'} in main project
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Your main project has {uncommittedCount} uncommitted {uncommittedCount === 1 ? 'change' : 'changes'}.
|
||||
Please commit or stash them before staging to avoid conflicts.
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Commit or stash them before staging to avoid conflicts.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `stash-${task.id}`,
|
||||
cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined
|
||||
});
|
||||
}}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
Open Terminal to Stash
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `stash-${task.id}`,
|
||||
cwd: worktreeStatus.worktreePath?.replace('.worktrees/' + task.specId, '') || undefined
|
||||
});
|
||||
}}
|
||||
className="text-xs h-6 mt-2"
|
||||
>
|
||||
<Terminal className="h-3 w-3 mr-1" />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onShowDiffDialog(true)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Changes
|
||||
</Button>
|
||||
{mergePreview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.warn('[WorkspaceStatus] Refresh conflicts clicked');
|
||||
onLoadMergePreview();
|
||||
}}
|
||||
disabled={isLoadingPreview}
|
||||
className="flex-none"
|
||||
title="Refresh conflict check"
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{worktreeStatus.worktreePath && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.electronAPI.createTerminal({
|
||||
id: `open-${task.id}`,
|
||||
cwd: worktreeStatus.worktreePath!
|
||||
});
|
||||
}}
|
||||
className="flex-none"
|
||||
title="Open worktree in terminal"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoadingPreview && !mergePreview && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking for conflicts...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Status */}
|
||||
{mergePreview && (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-2.5 rounded-lg border",
|
||||
hasGitConflicts
|
||||
? "bg-warning/10 border-warning/20"
|
||||
: !hasAIConflicts
|
||||
? "bg-success/10 border-success/20"
|
||||
: "bg-warning/10 border-warning/20"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasGitConflicts ? (
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-warning">Branch Diverged</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">AI will resolve</span>
|
||||
</div>
|
||||
</>
|
||||
) : !hasAIConflicts ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span className="text-sm font-medium text-success">Ready to merge</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{mergePreview.summary.totalFiles} files
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{mergePreview.conflicts.length} conflict{mergePreview.conflicts.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(hasGitConflicts || hasAIConflicts) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onShowConflictDialog(true)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadMergePreview}
|
||||
disabled={isLoadingPreview}
|
||||
className="h-7 px-2"
|
||||
title="Refresh"
|
||||
>
|
||||
{isLoadingPreview ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Git Conflicts Details */}
|
||||
{hasGitConflicts && mergePreview?.gitConflicts && (
|
||||
<div className="text-xs text-muted-foreground pl-6">
|
||||
Main branch has {mergePreview.gitConflicts.commitsBehind} new commit{mergePreview.gitConflicts.commitsBehind !== 1 ? 's' : ''}.
|
||||
{mergePreview.gitConflicts.conflictingFiles.length > 0 && (
|
||||
<span className="text-warning">
|
||||
{' '}{mergePreview.gitConflicts.conflictingFiles.length} file{mergePreview.gitConflicts.conflictingFiles.length !== 1 ? 's' : ''} need merging.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading indicator while checking conflicts */}
|
||||
{isLoadingPreview && !mergePreview && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm mb-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Checking for conflicts...
|
||||
{/* Actions Footer */}
|
||||
<div className="px-4 py-3 bg-muted/20 border-t border-border space-y-3">
|
||||
{/* Stage Only Option */}
|
||||
<label className="inline-flex items-center gap-2.5 text-sm cursor-pointer select-none px-3 py-2 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
|
||||
<Checkbox
|
||||
checked={stageOnly}
|
||||
onCheckedChange={(checked) => onStageOnlyChange(checked === true)}
|
||||
className="border-muted-foreground/50 data-[state=checked]:border-primary"
|
||||
/>
|
||||
<span className={cn(
|
||||
"transition-colors",
|
||||
stageOnly ? "text-foreground" : "text-muted-foreground"
|
||||
)}>Stage only (review in IDE before committing)</span>
|
||||
</label>
|
||||
|
||||
{/* Primary Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={hasGitConflicts ? "warning" : "success"}
|
||||
onClick={onMerge}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{hasGitConflicts ? 'Resolving...' : stageOnly ? 'Staging...' : 'Merging...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="mr-2 h-4 w-4" />
|
||||
{hasGitConflicts
|
||||
? (stageOnly ? 'Stage with AI Merge' : 'Merge with AI')
|
||||
: (stageOnly ? 'Stage Changes' : 'Merge to Main')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onShowDiscardDialog(true)}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 hover:border-destructive/30"
|
||||
title="Discard build"
|
||||
>
|
||||
<FolderX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Preview Summary */}
|
||||
{mergePreview && (
|
||||
<MergePreviewSummary
|
||||
mergePreview={mergePreview}
|
||||
onShowConflictDialog={onShowConflictDialog}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stage Only Option */}
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stageOnly}
|
||||
onChange={(e) => onStageOnlyChange(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span>Stage only (review in IDE before committing)</span>
|
||||
</label>
|
||||
|
||||
{/* Primary Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={hasGitConflicts ? "warning" : "success"}
|
||||
onClick={onMerge}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="flex-1"
|
||||
title={hasGitConflicts ? "AI will resolve conflicts automatically" : undefined}
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{hasGitConflicts
|
||||
? 'AI Resolving Conflicts...'
|
||||
: stageOnly ? 'Staging...' : 'Merging...'}
|
||||
</>
|
||||
) : hasGitConflicts ? (
|
||||
<>
|
||||
<GitMerge className="mr-2 h-4 w-4" />
|
||||
{stageOnly ? 'Stage with AI Merge' : 'Merge with AI'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge className="mr-2 h-4 w-4" />
|
||||
{stageOnly ? 'Stage Changes' : 'Merge to Main'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onShowDiscardDialog(true)}
|
||||
disabled={isMerging || isDiscarding}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<FolderX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -204,6 +204,810 @@
|
||||
--shadow-focus: 0 0 0 2px rgba(214, 216, 118, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DUSK THEME (Light)
|
||||
Warm, muted palette inspired by Fey/Oscura
|
||||
============================================ */
|
||||
[data-theme="dusk"] {
|
||||
/* Backgrounds */
|
||||
--background: #F5F5F0;
|
||||
--foreground: #131419;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #131419;
|
||||
|
||||
/* Primary accent - muted olive/yellow */
|
||||
--primary: #B8B978;
|
||||
--primary-foreground: #131419;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #EAEAE5;
|
||||
--secondary-foreground: #131419;
|
||||
|
||||
/* Muted */
|
||||
--muted: #F0F0EB;
|
||||
--muted-foreground: #5C6974;
|
||||
|
||||
/* Accent */
|
||||
--accent: #F0F0E0;
|
||||
--accent-foreground: #B8B978;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #D84F68;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #E0E0DB;
|
||||
--input: #E0E0DB;
|
||||
--ring: #B8B978;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #131419;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #131419;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #4EBE96;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #E0F5ED;
|
||||
--warning: #D2D714;
|
||||
--warning-foreground: #131419;
|
||||
--warning-light: #F5F5D0;
|
||||
--info: #479FFA;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #E8F4FF;
|
||||
--error: #D84F68;
|
||||
--error-light: #FCE8EC;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(184, 185, 120, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DUSK THEME (Dark)
|
||||
Fey-inspired dark theme
|
||||
============================================ */
|
||||
[data-theme="dusk"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #131419;
|
||||
--foreground: #E6E6E6;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #1A1B21;
|
||||
--card-foreground: #E6E6E6;
|
||||
|
||||
/* Primary accent - pale yellow */
|
||||
--primary: #E6E7A3;
|
||||
--primary-foreground: #131419;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #222329;
|
||||
--secondary-foreground: #E6E6E6;
|
||||
|
||||
/* Muted */
|
||||
--muted: #16171D;
|
||||
--muted-foreground: #868F97;
|
||||
|
||||
/* Accent */
|
||||
--accent: #2A2B1F;
|
||||
--accent-foreground: #E6E7A3;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #D84F68;
|
||||
--destructive-foreground: #131419;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #282828;
|
||||
--input: #282828;
|
||||
--ring: #E6E7A3;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #16171D;
|
||||
--sidebar-foreground: #E6E6E6;
|
||||
|
||||
/* Popover */
|
||||
--popover: #222329;
|
||||
--popover-foreground: #E6E6E6;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #4EBE96;
|
||||
--success-foreground: #131419;
|
||||
--success-light: #1A2E28;
|
||||
--warning: #D2D714;
|
||||
--warning-foreground: #131419;
|
||||
--warning-light: #2A2B1A;
|
||||
--info: #479FFA;
|
||||
--info-foreground: #131419;
|
||||
--info-light: #1A2433;
|
||||
--error: #D84F68;
|
||||
--error-light: #2E1A1F;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-focus: 0 0 0 2px rgba(230, 231, 163, 0.25);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIME THEME (Light)
|
||||
Fresh, energetic lime/chartreuse theme
|
||||
============================================ */
|
||||
[data-theme="lime"] {
|
||||
/* Backgrounds */
|
||||
--background: #E8F5A3;
|
||||
--foreground: #1A1A2E;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #1A1A2E;
|
||||
|
||||
/* Primary accent - purple for contrast against lime */
|
||||
--primary: #7C3AED;
|
||||
--primary-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #F5F9E8;
|
||||
--secondary-foreground: #1A1A2E;
|
||||
|
||||
/* Muted */
|
||||
--muted: #F8FAFC;
|
||||
--muted-foreground: #64748B;
|
||||
|
||||
/* Accent */
|
||||
--accent: #EDE9FE;
|
||||
--accent-foreground: #7C3AED;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #E2E8F0;
|
||||
--input: #E2E8F0;
|
||||
--ring: #7C3AED;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #1A1A2E;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #1A1A2E;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #059669;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #D1FAE5;
|
||||
--warning: #D97706;
|
||||
--warning-foreground: #FFFFFF;
|
||||
--warning-light: #FEF3C7;
|
||||
--info: #2563EB;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #DBEAFE;
|
||||
--error: #DC2626;
|
||||
--error-light: #FEE2E2;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LIME THEME (Dark)
|
||||
Fresh lime theme with deep purple undertones
|
||||
============================================ */
|
||||
[data-theme="lime"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #0F0F1A;
|
||||
--foreground: #F8FAFC;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #1E1E2E;
|
||||
--card-foreground: #F8FAFC;
|
||||
|
||||
/* Primary accent - bright purple for dark mode */
|
||||
--primary: #8B5CF6;
|
||||
--primary-foreground: #0F0F1A;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #1A1A2E;
|
||||
--secondary-foreground: #F8FAFC;
|
||||
|
||||
/* Muted */
|
||||
--muted: #13131F;
|
||||
--muted-foreground: #A1A1B5;
|
||||
|
||||
/* Accent */
|
||||
--accent: #2E2350;
|
||||
--accent-foreground: #8B5CF6;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #F87171;
|
||||
--destructive-foreground: #0F0F1A;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #2E2E40;
|
||||
--input: #2E2E40;
|
||||
--ring: #8B5CF6;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #13131F;
|
||||
--sidebar-foreground: #F8FAFC;
|
||||
|
||||
/* Popover */
|
||||
--popover: #262638;
|
||||
--popover-foreground: #F8FAFC;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #34D399;
|
||||
--success-foreground: #0F0F1A;
|
||||
--success-light: #134E4A;
|
||||
--warning: #FBBF24;
|
||||
--warning-foreground: #0F0F1A;
|
||||
--warning-light: #451A03;
|
||||
--info: #60A5FA;
|
||||
--info-foreground: #0F0F1A;
|
||||
--info-light: #1E3A8A;
|
||||
--error: #F87171;
|
||||
--error-light: #450A0A;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-focus: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
OCEAN THEME (Light)
|
||||
Calm, professional blue tones
|
||||
============================================ */
|
||||
[data-theme="ocean"] {
|
||||
/* Backgrounds */
|
||||
--background: #E0F2FE;
|
||||
--foreground: #0C4A6E;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #0C4A6E;
|
||||
|
||||
/* Primary accent - sky blue */
|
||||
--primary: #0284C7;
|
||||
--primary-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #F0F9FF;
|
||||
--secondary-foreground: #0C4A6E;
|
||||
|
||||
/* Muted */
|
||||
--muted: #F8FAFC;
|
||||
--muted-foreground: #64748B;
|
||||
|
||||
/* Accent */
|
||||
--accent: #E0F2FE;
|
||||
--accent-foreground: #0284C7;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #BAE6FD;
|
||||
--input: #BAE6FD;
|
||||
--ring: #0284C7;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #0C4A6E;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #0C4A6E;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #059669;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #D1FAE5;
|
||||
--warning: #D97706;
|
||||
--warning-foreground: #FFFFFF;
|
||||
--warning-light: #FEF3C7;
|
||||
--info: #2563EB;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #DBEAFE;
|
||||
--error: #DC2626;
|
||||
--error-light: #FEE2E2;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(2, 132, 199, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
OCEAN THEME (Dark)
|
||||
Deep ocean blue tones
|
||||
============================================ */
|
||||
[data-theme="ocean"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #082F49;
|
||||
--foreground: #F0F9FF;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #164E63;
|
||||
--card-foreground: #F0F9FF;
|
||||
|
||||
/* Primary accent - bright sky blue */
|
||||
--primary: #38BDF8;
|
||||
--primary-foreground: #082F49;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #0C4A6E;
|
||||
--secondary-foreground: #F0F9FF;
|
||||
|
||||
/* Muted */
|
||||
--muted: #0A3D5C;
|
||||
--muted-foreground: #7DD3FC;
|
||||
|
||||
/* Accent */
|
||||
--accent: #0C4A6E;
|
||||
--accent-foreground: #38BDF8;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #F87171;
|
||||
--destructive-foreground: #082F49;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #0E7490;
|
||||
--input: #0E7490;
|
||||
--ring: #38BDF8;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #0A3D5C;
|
||||
--sidebar-foreground: #F0F9FF;
|
||||
|
||||
/* Popover */
|
||||
--popover: #1E6B8A;
|
||||
--popover-foreground: #F0F9FF;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #34D399;
|
||||
--success-foreground: #082F49;
|
||||
--success-light: #134E4A;
|
||||
--warning: #FBBF24;
|
||||
--warning-foreground: #082F49;
|
||||
--warning-light: #451A03;
|
||||
--info: #60A5FA;
|
||||
--info-foreground: #082F49;
|
||||
--info-light: #1E3A8A;
|
||||
--error: #F87171;
|
||||
--error-light: #450A0A;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-focus: 0 0 0 3px rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RETRO THEME (Light)
|
||||
Warm, nostalgic amber/orange vibes
|
||||
============================================ */
|
||||
[data-theme="retro"] {
|
||||
/* Backgrounds */
|
||||
--background: #FEF3C7;
|
||||
--foreground: #78350F;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #78350F;
|
||||
|
||||
/* Primary accent - warm amber/orange */
|
||||
--primary: #D97706;
|
||||
--primary-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #FFFBEB;
|
||||
--secondary-foreground: #78350F;
|
||||
|
||||
/* Muted */
|
||||
--muted: #FEFCE8;
|
||||
--muted-foreground: #92400E;
|
||||
|
||||
/* Accent */
|
||||
--accent: #FEF3C7;
|
||||
--accent-foreground: #D97706;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #B91C1C;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #FDE68A;
|
||||
--input: #FDE68A;
|
||||
--ring: #D97706;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #78350F;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #78350F;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #15803D;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #DCFCE7;
|
||||
--warning: #CA8A04;
|
||||
--warning-foreground: #78350F;
|
||||
--warning-light: #FEF9C3;
|
||||
--info: #1D4ED8;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #DBEAFE;
|
||||
--error: #B91C1C;
|
||||
--error-light: #FEE2E2;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(217, 119, 6, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RETRO THEME (Dark)
|
||||
Warm stone/brown tones with golden accents
|
||||
============================================ */
|
||||
[data-theme="retro"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #1C1917;
|
||||
--foreground: #FEFCE8;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #44403C;
|
||||
--card-foreground: #FEFCE8;
|
||||
|
||||
/* Primary accent - bright amber/gold */
|
||||
--primary: #FBBF24;
|
||||
--primary-foreground: #1C1917;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #292524;
|
||||
--secondary-foreground: #FEFCE8;
|
||||
|
||||
/* Muted */
|
||||
--muted: #1C1917;
|
||||
--muted-foreground: #FDE68A;
|
||||
|
||||
/* Accent */
|
||||
--accent: #451A03;
|
||||
--accent-foreground: #FBBF24;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #F87171;
|
||||
--destructive-foreground: #1C1917;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #78716C;
|
||||
--input: #78716C;
|
||||
--ring: #FBBF24;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #1C1917;
|
||||
--sidebar-foreground: #FEFCE8;
|
||||
|
||||
/* Popover */
|
||||
--popover: #57534E;
|
||||
--popover-foreground: #FEFCE8;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #4ADE80;
|
||||
--success-foreground: #1C1917;
|
||||
--success-light: #14532D;
|
||||
--warning: #FACC15;
|
||||
--warning-foreground: #1C1917;
|
||||
--warning-light: #422006;
|
||||
--info: #60A5FA;
|
||||
--info-foreground: #1C1917;
|
||||
--info-light: #1E3A8A;
|
||||
--error: #F87171;
|
||||
--error-light: #450A0A;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-focus: 0 0 0 3px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
NEO THEME (Light)
|
||||
Modern, cyberpunk-inspired pink/purple palette
|
||||
============================================ */
|
||||
[data-theme="neo"] {
|
||||
/* Backgrounds */
|
||||
--background: #FDF4FF;
|
||||
--foreground: #581C87;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #581C87;
|
||||
|
||||
/* Primary accent - fuchsia/magenta */
|
||||
--primary: #D946EF;
|
||||
--primary-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #FAF5FF;
|
||||
--secondary-foreground: #581C87;
|
||||
|
||||
/* Muted */
|
||||
--muted: #F5F3FF;
|
||||
--muted-foreground: #7C3AED;
|
||||
|
||||
/* Accent */
|
||||
--accent: #FAE8FF;
|
||||
--accent-foreground: #D946EF;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #E11D48;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #F0ABFC;
|
||||
--input: #F0ABFC;
|
||||
--ring: #D946EF;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #581C87;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #581C87;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #06B6D4;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #CFFAFE;
|
||||
--warning: #F59E0B;
|
||||
--warning-foreground: #581C87;
|
||||
--warning-light: #FEF3C7;
|
||||
--info: #8B5CF6;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #EDE9FE;
|
||||
--error: #E11D48;
|
||||
--error-light: #FFE4E6;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(217, 70, 239, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
NEO THEME (Dark)
|
||||
Cyberpunk dark mode with neon pink/purple glow
|
||||
============================================ */
|
||||
[data-theme="neo"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #0F0720;
|
||||
--foreground: #FAF5FF;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #2D1B4E;
|
||||
--card-foreground: #FAF5FF;
|
||||
|
||||
/* Primary accent - bright pink/fuchsia */
|
||||
--primary: #F0ABFC;
|
||||
--primary-foreground: #0F0720;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #1A0A30;
|
||||
--secondary-foreground: #FAF5FF;
|
||||
|
||||
/* Muted */
|
||||
--muted: #150825;
|
||||
--muted-foreground: #E879F9;
|
||||
|
||||
/* Accent */
|
||||
--accent: #581C87;
|
||||
--accent-foreground: #F0ABFC;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #FB7185;
|
||||
--destructive-foreground: #0F0720;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #581C87;
|
||||
--input: #581C87;
|
||||
--ring: #F0ABFC;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #150825;
|
||||
--sidebar-foreground: #FAF5FF;
|
||||
|
||||
/* Popover */
|
||||
--popover: #3D2563;
|
||||
--popover-foreground: #FAF5FF;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #22D3EE;
|
||||
--success-foreground: #0F0720;
|
||||
--success-light: #164E63;
|
||||
--warning: #FBBF24;
|
||||
--warning-foreground: #0F0720;
|
||||
--warning-light: #451A03;
|
||||
--info: #A78BFA;
|
||||
--info-foreground: #0F0720;
|
||||
--info-light: #4C1D95;
|
||||
--error: #FB7185;
|
||||
--error-light: #4C0519;
|
||||
|
||||
/* Shadows - with subtle neon glow effect */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4), 0 0 20px rgba(217, 70, 239, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 0 30px rgba(217, 70, 239, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 0 40px rgba(217, 70, 239, 0.15);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 0 50px rgba(217, 70, 239, 0.2);
|
||||
--shadow-focus: 0 0 0 3px rgba(240, 171, 252, 0.4);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FOREST THEME (Light)
|
||||
Natural, earthy green tones
|
||||
============================================ */
|
||||
[data-theme="forest"] {
|
||||
/* Backgrounds */
|
||||
--background: #DCFCE7;
|
||||
--foreground: #14532D;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #14532D;
|
||||
|
||||
/* Primary accent - natural green */
|
||||
--primary: #16A34A;
|
||||
--primary-foreground: #FFFFFF;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #F0FDF4;
|
||||
--secondary-foreground: #14532D;
|
||||
|
||||
/* Muted */
|
||||
--muted: #ECFDF5;
|
||||
--muted-foreground: #166534;
|
||||
|
||||
/* Accent */
|
||||
--accent: #DCFCE7;
|
||||
--accent-foreground: #16A34A;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #DC2626;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #86EFAC;
|
||||
--input: #86EFAC;
|
||||
--ring: #16A34A;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #FFFFFF;
|
||||
--sidebar-foreground: #14532D;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #14532D;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #059669;
|
||||
--success-foreground: #FFFFFF;
|
||||
--success-light: #D1FAE5;
|
||||
--warning: #CA8A04;
|
||||
--warning-foreground: #14532D;
|
||||
--warning-light: #FEF9C3;
|
||||
--info: #0284C7;
|
||||
--info-foreground: #FFFFFF;
|
||||
--info-light: #E0F2FE;
|
||||
--error: #DC2626;
|
||||
--error-light: #FEE2E2;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-focus: 0 0 0 3px rgba(22, 163, 74, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FOREST THEME (Dark)
|
||||
Deep forest green tones with bright green accents
|
||||
============================================ */
|
||||
[data-theme="forest"].dark {
|
||||
/* Backgrounds */
|
||||
--background: #052E16;
|
||||
--foreground: #F0FDF4;
|
||||
|
||||
/* Card surfaces */
|
||||
--card: #166534;
|
||||
--card-foreground: #F0FDF4;
|
||||
|
||||
/* Primary accent - bright green for dark mode */
|
||||
--primary: #4ADE80;
|
||||
--primary-foreground: #052E16;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: #14532D;
|
||||
--secondary-foreground: #F0FDF4;
|
||||
|
||||
/* Muted */
|
||||
--muted: #0A3D1F;
|
||||
--muted-foreground: #86EFAC;
|
||||
|
||||
/* Accent */
|
||||
--accent: #14532D;
|
||||
--accent-foreground: #4ADE80;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #F87171;
|
||||
--destructive-foreground: #052E16;
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: #166534;
|
||||
--input: #166534;
|
||||
--ring: #4ADE80;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: #0A3D1F;
|
||||
--sidebar-foreground: #F0FDF4;
|
||||
|
||||
/* Popover */
|
||||
--popover: #15803D;
|
||||
--popover-foreground: #F0FDF4;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: #34D399;
|
||||
--success-foreground: #052E16;
|
||||
--success-light: #064E3B;
|
||||
--warning: #FBBF24;
|
||||
--warning-foreground: #052E16;
|
||||
--warning-light: #451A03;
|
||||
--info: #38BDF8;
|
||||
--info-foreground: #052E16;
|
||||
--info-light: #0C4A6E;
|
||||
--error: #F87171;
|
||||
--error-light: #450A0A;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
|
||||
--shadow-focus: 0 0 0 3px rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
border-color: var(--border);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export const DEFAULT_APP_SETTINGS = {
|
||||
theme: 'system' as const,
|
||||
colorTheme: 'default' as const,
|
||||
defaultModel: 'opus',
|
||||
agentFramework: 'auto-claude',
|
||||
pythonPath: undefined as string | undefined,
|
||||
|
||||
@@ -21,6 +21,9 @@ export * from './changelog';
|
||||
// Model and agent profile constants
|
||||
export * from './models';
|
||||
|
||||
// Theme constants
|
||||
export * from './themes';
|
||||
|
||||
// GitHub integration constants
|
||||
export * from './github';
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Theme constants
|
||||
* Color themes for multi-theme support with light/dark mode variants
|
||||
*/
|
||||
|
||||
import type { ColorThemeDefinition } from '../types/settings';
|
||||
|
||||
// ============================================
|
||||
// Color Themes
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* All available color themes with preview colors for the theme selector.
|
||||
* Each theme has both light and dark mode variants defined in CSS.
|
||||
*/
|
||||
export const COLOR_THEMES: ColorThemeDefinition[] = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Oscura-inspired with pale yellow accent',
|
||||
previewColors: { bg: '#F2F2ED', accent: '#E6E7A3', darkBg: '#0B0B0F', darkAccent: '#E6E7A3' }
|
||||
},
|
||||
{
|
||||
id: 'dusk',
|
||||
name: 'Dusk',
|
||||
description: 'Warmer variant with slightly lighter dark mode',
|
||||
previewColors: { bg: '#F5F5F0', accent: '#E6E7A3', darkBg: '#131419', darkAccent: '#E6E7A3' }
|
||||
},
|
||||
{
|
||||
id: 'lime',
|
||||
name: 'Lime',
|
||||
description: 'Fresh, energetic lime with purple accents',
|
||||
previewColors: { bg: '#E8F5A3', accent: '#7C3AED', darkBg: '#0F0F1A' }
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
name: 'Ocean',
|
||||
description: 'Calm, professional blue tones',
|
||||
previewColors: { bg: '#E0F2FE', accent: '#0284C7', darkBg: '#082F49' }
|
||||
},
|
||||
{
|
||||
id: 'retro',
|
||||
name: 'Retro',
|
||||
description: 'Warm, nostalgic amber vibes',
|
||||
previewColors: { bg: '#FEF3C7', accent: '#D97706', darkBg: '#1C1917' }
|
||||
},
|
||||
{
|
||||
id: 'neo',
|
||||
name: 'Neo',
|
||||
description: 'Modern cyberpunk pink/magenta',
|
||||
previewColors: { bg: '#FDF4FF', accent: '#D946EF', darkBg: '#0F0720' }
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
name: 'Forest',
|
||||
description: 'Natural, earthy green tones',
|
||||
previewColors: { bg: '#DCFCE7', accent: '#16A34A', darkBg: '#052E16' }
|
||||
}
|
||||
];
|
||||
@@ -319,7 +319,14 @@ export interface ElectronAPI {
|
||||
// GitHub OAuth operations (gh CLI)
|
||||
checkGitHubCli: () => Promise<IPCResult<{ installed: boolean; version?: string }>>;
|
||||
checkGitHubAuth: () => Promise<IPCResult<{ authenticated: boolean; username?: string }>>;
|
||||
startGitHubAuth: () => Promise<IPCResult<{ success: boolean; message?: string }>>;
|
||||
startGitHubAuth: () => Promise<IPCResult<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
deviceCode?: string;
|
||||
authUrl?: string;
|
||||
browserOpened?: boolean;
|
||||
fallbackUrl?: string;
|
||||
}>>;
|
||||
getGitHubToken: () => Promise<IPCResult<{ token: string }>>;
|
||||
getGitHubUser: () => Promise<IPCResult<{ username: string; name?: string }>>;
|
||||
listGitHubUserRepos: () => Promise<IPCResult<{ repos: Array<{ fullName: string; description: string | null; isPrivate: boolean }> }>>;
|
||||
|
||||
@@ -186,31 +186,61 @@ export interface GraphitiConnectionTestResult {
|
||||
}
|
||||
|
||||
// Graphiti Provider Types (Memory System V2)
|
||||
export type GraphitiProviderType = 'openai' | 'anthropic' | 'google' | 'groq' | 'ollama';
|
||||
export type GraphitiEmbeddingProvider = 'openai' | 'voyage' | 'google' | 'huggingface' | 'ollama';
|
||||
// LLM Providers: OpenAI, Anthropic, Azure OpenAI, Ollama (local), Google, Groq
|
||||
export type GraphitiLLMProvider = 'openai' | 'anthropic' | 'azure_openai' | 'ollama' | 'google' | 'groq';
|
||||
// Embedding Providers: OpenAI, Voyage AI, Azure OpenAI, Ollama (local), Google, HuggingFace
|
||||
export type GraphitiEmbeddingProvider = 'openai' | 'voyage' | 'azure_openai' | 'ollama' | 'google' | 'huggingface';
|
||||
|
||||
// Legacy type alias for backward compatibility
|
||||
export type GraphitiProviderType = GraphitiLLMProvider;
|
||||
|
||||
export interface GraphitiProviderConfig {
|
||||
// LLM Provider
|
||||
llmProvider: GraphitiProviderType;
|
||||
llmProvider: GraphitiLLMProvider;
|
||||
llmModel?: string; // Model name, uses provider default if not specified
|
||||
|
||||
// Embedding Provider
|
||||
embeddingProvider: GraphitiEmbeddingProvider;
|
||||
embeddingModel?: string; // Embedding model, uses provider default if not specified
|
||||
|
||||
// Provider-specific API keys (stored securely)
|
||||
// OpenAI settings
|
||||
openaiApiKey?: string;
|
||||
anthropicApiKey?: string;
|
||||
googleApiKey?: string;
|
||||
groqApiKey?: string;
|
||||
voyageApiKey?: string;
|
||||
openaiModel?: string;
|
||||
openaiEmbeddingModel?: string;
|
||||
|
||||
// Ollama-specific config (local LLM, no API key required)
|
||||
// Anthropic settings (LLM only - needs separate embedder)
|
||||
anthropicApiKey?: string;
|
||||
anthropicModel?: string;
|
||||
|
||||
// Azure OpenAI settings
|
||||
azureOpenaiApiKey?: string;
|
||||
azureOpenaiBaseUrl?: string;
|
||||
azureOpenaiLlmDeployment?: string;
|
||||
azureOpenaiEmbeddingDeployment?: string;
|
||||
|
||||
// Voyage AI settings (embeddings only - commonly used with Anthropic)
|
||||
voyageApiKey?: string;
|
||||
voyageEmbeddingModel?: string;
|
||||
|
||||
// Google AI settings (LLM and embeddings)
|
||||
googleApiKey?: string;
|
||||
googleLlmModel?: string;
|
||||
googleEmbeddingModel?: string;
|
||||
|
||||
// Ollama settings (local LLM, no API key required)
|
||||
ollamaBaseUrl?: string; // Default: http://localhost:11434
|
||||
ollamaLlmModel?: string;
|
||||
ollamaEmbeddingModel?: string;
|
||||
ollamaEmbeddingDim?: number;
|
||||
|
||||
// Groq settings
|
||||
groqApiKey?: string;
|
||||
groqModel?: string;
|
||||
|
||||
// HuggingFace settings (embeddings only)
|
||||
huggingfaceApiKey?: string;
|
||||
huggingfaceEmbeddingModel?: string;
|
||||
|
||||
// FalkorDB connection (required for all providers)
|
||||
falkorDbHost?: string;
|
||||
falkorDbPort?: number;
|
||||
|
||||
@@ -5,6 +5,23 @@
|
||||
import type { NotificationSettings } from './project';
|
||||
import type { ChangelogFormat, ChangelogAudience, ChangelogEmojiLevel } from './changelog';
|
||||
|
||||
// Color theme types for multi-theme support
|
||||
export type ColorTheme = 'default' | 'dusk' | 'lime' | 'ocean' | 'retro' | 'neo' | 'forest';
|
||||
|
||||
export interface ThemePreviewColors {
|
||||
bg: string;
|
||||
accent: string;
|
||||
darkBg: string;
|
||||
darkAccent?: string;
|
||||
}
|
||||
|
||||
export interface ColorThemeDefinition {
|
||||
id: ColorTheme;
|
||||
name: string;
|
||||
description: string;
|
||||
previewColors: ThemePreviewColors;
|
||||
}
|
||||
|
||||
// Thinking level for Claude model (budget token allocation)
|
||||
export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
|
||||
@@ -44,6 +61,7 @@ export interface AgentProfile {
|
||||
|
||||
export interface AppSettings {
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
colorTheme?: ColorTheme;
|
||||
defaultModel: string;
|
||||
agentFramework: string;
|
||||
pythonPath?: string;
|
||||
@@ -64,10 +82,15 @@ export interface AppSettings {
|
||||
onboardingCompleted?: boolean;
|
||||
// Selected agent profile for preset model/thinking configurations
|
||||
selectedAgentProfile?: string;
|
||||
// Custom phase configuration for Auto profile (overrides defaults)
|
||||
customPhaseModels?: PhaseModelConfig;
|
||||
customPhaseThinking?: PhaseThinkingConfig;
|
||||
// Changelog preferences
|
||||
changelogFormat?: ChangelogFormat;
|
||||
changelogAudience?: ChangelogAudience;
|
||||
changelogEmojiLevel?: ChangelogEmojiLevel;
|
||||
// Migration flags (internal use)
|
||||
_migratedAgentProfileToAuto?: boolean;
|
||||
}
|
||||
|
||||
// Auto-Claude Source Environment Configuration (for auto-claude repo .env)
|
||||
|
||||
@@ -356,6 +356,8 @@ export interface WorktreeMergeResult {
|
||||
staged?: boolean;
|
||||
alreadyStaged?: boolean;
|
||||
projectPath?: string;
|
||||
// AI-generated commit message suggestion (for stage-only mode)
|
||||
suggestedCommitMessage?: string;
|
||||
// New conflict info from smart merge
|
||||
conflicts?: MergeConflict[];
|
||||
stats?: MergeStats;
|
||||
|
||||
@@ -51,6 +51,33 @@ export function buildCdCommand(path: string | undefined): string {
|
||||
return `cd ${escapeShellPath(path)} && `;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use as a Windows cmd.exe argument.
|
||||
*
|
||||
* Windows cmd.exe uses different escaping rules than POSIX shells.
|
||||
* This function escapes special characters that could break out of strings
|
||||
* or execute additional commands.
|
||||
*
|
||||
* @param arg - The argument to escape
|
||||
* @returns The escaped argument safe for use in cmd.exe
|
||||
*/
|
||||
export function escapeShellArgWindows(arg: string): string {
|
||||
// Escape characters that have special meaning in cmd.exe:
|
||||
// ^ is the escape character in cmd.exe
|
||||
// " & | < > ^ need to be escaped
|
||||
// % is used for variable expansion
|
||||
const escaped = arg
|
||||
.replace(/\^/g, '^^') // Escape carets first (escape char itself)
|
||||
.replace(/"/g, '^"') // Escape double quotes
|
||||
.replace(/&/g, '^&') // Escape ampersand (command separator)
|
||||
.replace(/\|/g, '^|') // Escape pipe
|
||||
.replace(/</g, '^<') // Escape less than
|
||||
.replace(/>/g, '^>') // Escape greater than
|
||||
.replace(/%/g, '%%'); // Escape percent (variable expansion)
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path doesn't contain obviously malicious patterns.
|
||||
* This is a defense-in-depth measure - escaping should handle all cases,
|
||||
|
||||
@@ -5,13 +5,13 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'],
|
||||
exclude: ['node_modules', 'dist', 'out'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.d.ts']
|
||||
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx', 'src/**/*.d.ts']
|
||||
},
|
||||
// Mock Electron modules for unit tests
|
||||
alias: {
|
||||
|
||||
@@ -140,10 +140,10 @@
|
||||
# Choose which providers to use for LLM and embeddings.
|
||||
# Default is "openai" for both.
|
||||
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama | google
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama | google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
|
||||
# =============================================================================
|
||||
@@ -191,6 +191,23 @@
|
||||
# Available: voyage-3 (1024 dim), voyage-3-lite (512 dim)
|
||||
# VOYAGE_EMBEDDING_MODEL=voyage-3
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Google AI Provider
|
||||
# =============================================================================
|
||||
# Use Google AI (Gemini) for both LLM and embeddings.
|
||||
# Get API key from: https://aistudio.google.com/apikey
|
||||
#
|
||||
# Required: GOOGLE_API_KEY
|
||||
|
||||
# Google AI API Key
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Google LLM Model (default: gemini-2.0-flash)
|
||||
# GOOGLE_LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Google Embedding Model (default: text-embedding-004)
|
||||
# GOOGLE_EMBEDDING_MODEL=text-embedding-004
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Azure OpenAI Provider
|
||||
# =============================================================================
|
||||
@@ -331,3 +348,9 @@
|
||||
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/...
|
||||
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-5
|
||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
||||
#
|
||||
# --- Example 5: Google AI (Gemini) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=google
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxx
|
||||
|
||||
@@ -189,7 +189,83 @@ def handle_merge_command(
|
||||
Returns:
|
||||
True if merge succeeded, False otherwise
|
||||
"""
|
||||
return merge_existing_build(project_dir, spec_name, no_commit=no_commit)
|
||||
success = merge_existing_build(project_dir, spec_name, no_commit=no_commit)
|
||||
|
||||
# Generate commit message suggestion if staging succeeded (no_commit mode)
|
||||
if success and no_commit:
|
||||
_generate_and_save_commit_message(project_dir, spec_name)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None:
|
||||
"""
|
||||
Generate a commit message suggestion and save it for the UI.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
"""
|
||||
try:
|
||||
from commit_message import generate_commit_message_sync
|
||||
|
||||
# Get diff summary for context
|
||||
diff_summary = ""
|
||||
files_changed = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--staged", "--stat"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
diff_summary = result.stdout.strip()
|
||||
|
||||
# Get list of changed files
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--staged", "--name-only"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
files_changed = [
|
||||
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
|
||||
]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not get diff summary: {e}")
|
||||
|
||||
# Generate commit message
|
||||
debug(MODULE, "Generating commit message suggestion...")
|
||||
commit_message = generate_commit_message_sync(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_name,
|
||||
diff_summary=diff_summary,
|
||||
files_changed=files_changed,
|
||||
)
|
||||
|
||||
if commit_message:
|
||||
# Save to spec directory for UI to read
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
if spec_dir.exists():
|
||||
commit_msg_file = spec_dir / "suggested_commit_message.txt"
|
||||
commit_msg_file.write_text(commit_message, encoding="utf-8")
|
||||
debug_success(
|
||||
MODULE, f"Saved commit message suggestion to {commit_msg_file}"
|
||||
)
|
||||
else:
|
||||
debug_warning(MODULE, f"Spec directory not found: {spec_dir}")
|
||||
else:
|
||||
debug_warning(MODULE, "No commit message generated")
|
||||
|
||||
except ImportError:
|
||||
debug_warning(MODULE, "commit_message module not available")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to generate commit message: {e}")
|
||||
|
||||
|
||||
def handle_review_command(project_dir: Path, spec_name: str) -> None:
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Commit Message Generator
|
||||
========================
|
||||
|
||||
Generates high-quality commit messages using Claude Haiku.
|
||||
|
||||
Features:
|
||||
- Conventional commits format (feat/fix/refactor/etc)
|
||||
- GitHub issue references (Fixes #123)
|
||||
- Context-aware descriptions from spec metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map task categories to conventional commit types
|
||||
CATEGORY_TO_COMMIT_TYPE = {
|
||||
"feature": "feat",
|
||||
"bug_fix": "fix",
|
||||
"bug": "fix",
|
||||
"refactoring": "refactor",
|
||||
"refactor": "refactor",
|
||||
"documentation": "docs",
|
||||
"docs": "docs",
|
||||
"testing": "test",
|
||||
"test": "test",
|
||||
"performance": "perf",
|
||||
"perf": "perf",
|
||||
"security": "security",
|
||||
"chore": "chore",
|
||||
"style": "style",
|
||||
"ci": "ci",
|
||||
"build": "build",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """You are a Git expert who writes clear, concise commit messages following conventional commits format.
|
||||
|
||||
Rules:
|
||||
1. First line: type(scope): description (max 72 chars total)
|
||||
2. Leave blank line after first line
|
||||
3. Body: 1-3 sentences explaining WHAT changed and WHY
|
||||
4. If GitHub issue number provided, end with "Fixes #N" on its own line
|
||||
5. Be specific about the changes, not generic
|
||||
6. Use imperative mood ("Add feature" not "Added feature")
|
||||
|
||||
Types: feat, fix, refactor, docs, test, perf, chore, style, ci, build
|
||||
|
||||
Example output:
|
||||
feat(auth): add OAuth2 login flow
|
||||
|
||||
Implement OAuth2 authentication with Google and GitHub providers.
|
||||
Add token refresh logic and secure storage.
|
||||
|
||||
Fixes #42"""
|
||||
|
||||
|
||||
def _get_spec_context(spec_dir: Path) -> dict:
|
||||
"""
|
||||
Extract context from spec files for commit message generation.
|
||||
|
||||
Returns dict with:
|
||||
- title: Feature/task title
|
||||
- category: Task category (feature, bug_fix, etc)
|
||||
- description: Brief description
|
||||
- github_issue: GitHub issue number if linked
|
||||
"""
|
||||
context = {
|
||||
"title": "",
|
||||
"category": "chore",
|
||||
"description": "",
|
||||
"github_issue": None,
|
||||
}
|
||||
|
||||
# Try to read spec.md for title
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.exists():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Extract title from first H1 or H2
|
||||
title_match = re.search(r"^#+ (.+)$", content, re.MULTILINE)
|
||||
if title_match:
|
||||
context["title"] = title_match.group(1).strip()
|
||||
|
||||
# Look for overview/description section
|
||||
overview_match = re.search(
|
||||
r"## Overview\s*\n(.+?)(?=\n##|\Z)", content, re.DOTALL
|
||||
)
|
||||
if overview_match:
|
||||
context["description"] = overview_match.group(1).strip()[:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read spec.md: {e}")
|
||||
|
||||
# Try to read requirements.json for metadata
|
||||
req_file = spec_dir / "requirements.json"
|
||||
if req_file.exists():
|
||||
try:
|
||||
req_data = json.loads(req_file.read_text(encoding="utf-8"))
|
||||
if not context["title"] and req_data.get("feature"):
|
||||
context["title"] = req_data["feature"]
|
||||
if req_data.get("workflow_type"):
|
||||
context["category"] = req_data["workflow_type"]
|
||||
if req_data.get("task_description") and not context["description"]:
|
||||
context["description"] = req_data["task_description"][:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read requirements.json: {e}")
|
||||
|
||||
# Try to read implementation_plan.json for GitHub issue
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
try:
|
||||
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
|
||||
# Check for GitHub metadata
|
||||
metadata = plan_data.get("metadata", {})
|
||||
if metadata.get("githubIssueNumber"):
|
||||
context["github_issue"] = metadata["githubIssueNumber"]
|
||||
# Fallback title
|
||||
if not context["title"]:
|
||||
context["title"] = plan_data.get("feature") or plan_data.get(
|
||||
"title", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read implementation_plan.json: {e}")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
spec_context: dict,
|
||||
diff_summary: str,
|
||||
files_changed: list[str],
|
||||
) -> str:
|
||||
"""Build the prompt for Claude."""
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
|
||||
github_ref = ""
|
||||
if spec_context.get("github_issue"):
|
||||
github_ref = f"\nGitHub Issue: #{spec_context['github_issue']} (include 'Fixes #{spec_context['github_issue']}' at the end)"
|
||||
|
||||
# Truncate file list if too long
|
||||
if len(files_changed) > 20:
|
||||
files_display = (
|
||||
"\n".join(files_changed[:20])
|
||||
+ f"\n... and {len(files_changed) - 20} more files"
|
||||
)
|
||||
else:
|
||||
files_display = (
|
||||
"\n".join(files_changed) if files_changed else "(no files listed)"
|
||||
)
|
||||
|
||||
prompt = f"""Generate a commit message for this change.
|
||||
|
||||
Task: {spec_context.get("title", "Unknown task")}
|
||||
Type: {commit_type}
|
||||
Files changed: {len(files_changed)}
|
||||
{github_ref}
|
||||
|
||||
Description: {spec_context.get("description", "No description available")}
|
||||
|
||||
Changed files:
|
||||
{files_display}
|
||||
|
||||
Diff summary:
|
||||
{diff_summary[:2000] if diff_summary else "(no diff available)"}
|
||||
|
||||
Generate ONLY the commit message, nothing else. Follow the format exactly:
|
||||
type(scope): short description
|
||||
|
||||
Body explaining changes.
|
||||
|
||||
Fixes #N (if applicable)"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude_haiku(prompt: str) -> str:
|
||||
"""Call Claude Haiku with low thinking for fast commit message generation."""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
return ""
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("claude_agent_sdk not installed")
|
||||
return ""
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
return response_text.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude SDK call failed: {e}")
|
||||
print(f" [WARN] Commit message generation failed: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def generate_commit_message_sync(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message synchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
# Try alternative location
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Already in an async context - run in a new thread
|
||||
# Use lambda to ensure coroutine is created inside the worker thread
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(
|
||||
lambda: asyncio.run(_call_claude_haiku(prompt))
|
||||
).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
async def generate_commit_message(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message asynchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude_haiku(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
+84
-10
@@ -6,7 +6,10 @@ for multiple environment variables, and SDK environment variable passthrough
|
||||
for custom API endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
# Priority order for auth token resolution
|
||||
AUTH_TOKEN_ENV_VARS = [
|
||||
@@ -27,30 +30,96 @@ SDK_ENV_VARS = [
|
||||
]
|
||||
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from macOS Keychain.
|
||||
|
||||
Reads Claude Code credentials from macOS Keychain and extracts the OAuth token.
|
||||
Only works on macOS (Darwin platform).
|
||||
|
||||
Returns:
|
||||
Token string if found in Keychain, None otherwise
|
||||
"""
|
||||
# Only attempt on macOS
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
try:
|
||||
# Query macOS Keychain for Claude Code credentials
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
"Claude Code-credentials",
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
# Parse JSON response
|
||||
credentials_json = result.stdout.strip()
|
||||
if not credentials_json:
|
||||
return None
|
||||
|
||||
data = json.loads(credentials_json)
|
||||
|
||||
# Extract OAuth token from nested structure
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Validate token format (Claude OAuth tokens start with sk-ant-oat01-)
|
||||
if not token.startswith("sk-ant-oat01-"):
|
||||
return None
|
||||
|
||||
return token
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
||||
# Silently fail - this is a fallback mechanism
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables.
|
||||
Get authentication token from environment variables or macOS Keychain.
|
||||
|
||||
Checks multiple env vars in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (original)
|
||||
2. ANTHROPIC_AUTH_TOKEN (ccr/proxy)
|
||||
3. ANTHROPIC_API_KEY (direct API key)
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (ccr/proxy env var)
|
||||
3. ANTHROPIC_API_KEY (direct API key env var)
|
||||
4. macOS Keychain (if on Darwin platform)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
# Fallback to macOS Keychain
|
||||
return get_token_from_keychain()
|
||||
|
||||
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the env var that provided the auth token."""
|
||||
"""Get the name of the source that provided the auth token."""
|
||||
# Check environment variables first
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from macOS Keychain
|
||||
if get_token_from_keychain():
|
||||
return "macOS Keychain"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -59,15 +128,20 @@ def require_auth_token() -> str:
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported env var
|
||||
ValueError: If no auth token is found in any supported source
|
||||
"""
|
||||
token = get_auth_token()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
error_msg = (
|
||||
"No authentication token found.\n"
|
||||
f"Set one of: {', '.join(AUTH_TOKEN_ENV_VARS)}\n"
|
||||
"For Claude Code CLI: run 'claude setup-token'"
|
||||
)
|
||||
# Provide platform-specific guidance
|
||||
if platform.system() == "Darwin":
|
||||
error_msg += "For Claude Code CLI: run 'claude setup-token' to save token to macOS Keychain"
|
||||
else:
|
||||
error_msg += "For Claude Code CLI: run 'claude setup-token'"
|
||||
raise ValueError(error_msg)
|
||||
return token
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ from core.workspace.display import (
|
||||
show_build_summary,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
MAX_PARALLEL_AI_MERGES,
|
||||
_is_auto_claude_file,
|
||||
get_existing_build_worktree,
|
||||
)
|
||||
@@ -97,6 +98,7 @@ from core.workspace.git_utils import (
|
||||
from core.workspace.models import (
|
||||
MergeLock,
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
)
|
||||
from merge import (
|
||||
@@ -858,13 +860,13 @@ def _resolve_git_conflicts_with_ai(
|
||||
start_time = time.time()
|
||||
|
||||
# Run parallel merges
|
||||
# TODO: _run_parallel_merges not yet implemented - see line 140
|
||||
# parallel_results = asyncio.run(_run_parallel_merges(
|
||||
# tasks=files_needing_ai_merge,
|
||||
# project_dir=project_dir,
|
||||
# max_concurrent=MAX_PARALLEL_AI_MERGES,
|
||||
# ))
|
||||
parallel_results = [] # Placeholder until function is implemented
|
||||
parallel_results = asyncio.run(
|
||||
_run_parallel_merges(
|
||||
tasks=files_needing_ai_merge,
|
||||
project_dir=project_dir,
|
||||
max_concurrent=MAX_PARALLEL_AI_MERGES,
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
@@ -996,3 +998,332 @@ def _resolve_git_conflicts_with_ai(
|
||||
# - Git utilities from workspace/git_utils.py
|
||||
# - Display functions from workspace/display.py
|
||||
# - Finalization functions from workspace/finalization.py
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel AI Merge Implementation
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
_merge_logger = logging.getLogger(__name__)
|
||||
|
||||
# System prompt for AI file merging
|
||||
AI_MERGE_SYSTEM_PROMPT = """You are an expert code merge assistant. Your task is to perform a 3-way merge of code files.
|
||||
|
||||
RULES:
|
||||
1. Preserve all functional changes from both versions (ours and theirs)
|
||||
2. Maintain code style consistency
|
||||
3. Resolve conflicts by understanding the semantic purpose of each change
|
||||
4. When changes are independent (different functions/sections), include both
|
||||
5. When changes overlap, combine them logically or prefer the more complete version
|
||||
6. Preserve all imports from both versions
|
||||
7. Output ONLY the merged code - no explanations, no markdown, no code fences
|
||||
|
||||
IMPORTANT: Output the raw merged file content only. Do not wrap in code blocks."""
|
||||
|
||||
|
||||
def _infer_language_from_path(file_path: str) -> str:
|
||||
"""Infer programming language from file extension."""
|
||||
ext_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".java": "java",
|
||||
".cpp": "cpp",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".scala": "scala",
|
||||
".json": "json",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".toml": "toml",
|
||||
".md": "markdown",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".sql": "sql",
|
||||
}
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
return ext_map.get(ext, "text")
|
||||
|
||||
|
||||
def _try_simple_3way_merge(
|
||||
base: str | None,
|
||||
ours: str,
|
||||
theirs: str,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Attempt a simple 3-way merge without AI.
|
||||
|
||||
Returns:
|
||||
(success, merged_content) - if success is True, merged_content is the result
|
||||
"""
|
||||
# If base is None, we can't do a proper 3-way merge
|
||||
if base is None:
|
||||
# If both are identical, no conflict
|
||||
if ours == theirs:
|
||||
return True, ours
|
||||
# Otherwise, we need AI to decide
|
||||
return False, None
|
||||
|
||||
# If ours equals base, theirs is the only change - take theirs
|
||||
if ours == base:
|
||||
return True, theirs
|
||||
|
||||
# If theirs equals base, ours is the only change - take ours
|
||||
if theirs == base:
|
||||
return True, ours
|
||||
|
||||
# If ours equals theirs, both made same change - take either
|
||||
if ours == theirs:
|
||||
return True, ours
|
||||
|
||||
# Both changed differently from base - need AI merge
|
||||
# We could try a line-by-line merge here, but for safety let's use AI
|
||||
return False, None
|
||||
|
||||
|
||||
def _build_merge_prompt(
|
||||
file_path: str,
|
||||
base_content: str | None,
|
||||
main_content: str,
|
||||
worktree_content: str,
|
||||
spec_name: str,
|
||||
) -> str:
|
||||
"""Build the prompt for AI file merge."""
|
||||
language = _infer_language_from_path(file_path)
|
||||
|
||||
base_section = ""
|
||||
if base_content:
|
||||
# Truncate very large files
|
||||
if len(base_content) > 10000:
|
||||
base_content = base_content[:10000] + "\n... (truncated)"
|
||||
base_section = f"""
|
||||
BASE (common ancestor):
|
||||
```{language}
|
||||
{base_content}
|
||||
```
|
||||
"""
|
||||
|
||||
# Truncate large content
|
||||
if len(main_content) > 15000:
|
||||
main_content = main_content[:15000] + "\n... (truncated)"
|
||||
if len(worktree_content) > 15000:
|
||||
worktree_content = worktree_content[:15000] + "\n... (truncated)"
|
||||
|
||||
prompt = f"""Perform a 3-way merge for file: {file_path}
|
||||
Task being merged: {spec_name}
|
||||
{base_section}
|
||||
OURS (current main branch):
|
||||
```{language}
|
||||
{main_content}
|
||||
```
|
||||
|
||||
THEIRS (changes from task worktree):
|
||||
```{language}
|
||||
{worktree_content}
|
||||
```
|
||||
|
||||
Merge these versions, preserving all meaningful changes from both. Output only the merged file content, no explanations."""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Remove markdown code fences if present."""
|
||||
# Check if content starts with code fence
|
||||
lines = content.strip().split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
# Remove first and last line if they're code fences
|
||||
if lines[-1].strip() == "```":
|
||||
return "\n".join(lines[1:-1])
|
||||
else:
|
||||
return "\n".join(lines[1:])
|
||||
return content
|
||||
|
||||
|
||||
async def _merge_file_with_ai_async(
|
||||
task: ParallelMergeTask,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> ParallelMergeResult:
|
||||
"""
|
||||
Merge a single file using AI.
|
||||
|
||||
Args:
|
||||
task: The merge task with file contents
|
||||
semaphore: Semaphore for concurrency control
|
||||
|
||||
Returns:
|
||||
ParallelMergeResult with merged content or error
|
||||
"""
|
||||
async with semaphore:
|
||||
try:
|
||||
# First try simple 3-way merge
|
||||
success, merged = _try_simple_3way_merge(
|
||||
task.base_content,
|
||||
task.main_content,
|
||||
task.worktree_content,
|
||||
)
|
||||
|
||||
if success and merged is not None:
|
||||
debug(MODULE, f"Auto-merged {task.file_path} without AI")
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=merged,
|
||||
success=True,
|
||||
was_auto_merged=True,
|
||||
)
|
||||
|
||||
# Need AI merge
|
||||
debug(MODULE, f"Using AI to merge {task.file_path}")
|
||||
|
||||
# Import auth utilities
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
if not get_auth_token():
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="No authentication token available",
|
||||
)
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_merge_prompt(
|
||||
task.file_path,
|
||||
task.base_content,
|
||||
task.main_content,
|
||||
task.worktree_content,
|
||||
task.spec_name,
|
||||
)
|
||||
|
||||
# Call Claude Haiku for fast merge
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="claude_agent_sdk not installed",
|
||||
)
|
||||
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
if response_text:
|
||||
# Strip any code fences the model might have added
|
||||
merged_content = _strip_code_fences(response_text.strip())
|
||||
|
||||
debug(MODULE, f"AI merged {task.file_path} successfully")
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=merged_content,
|
||||
success=True,
|
||||
was_auto_merged=False,
|
||||
)
|
||||
else:
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="AI returned empty response",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_merge_logger.error(f"Failed to merge {task.file_path}: {e}")
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
async def _run_parallel_merges(
|
||||
tasks: list[ParallelMergeTask],
|
||||
project_dir: Path,
|
||||
max_concurrent: int = MAX_PARALLEL_AI_MERGES,
|
||||
) -> list[ParallelMergeResult]:
|
||||
"""
|
||||
Run file merges in parallel with concurrency control.
|
||||
|
||||
Args:
|
||||
tasks: List of merge tasks to process
|
||||
project_dir: Project directory (for context, not currently used)
|
||||
max_concurrent: Maximum number of concurrent merge operations
|
||||
|
||||
Returns:
|
||||
List of ParallelMergeResult for each task
|
||||
"""
|
||||
if not tasks:
|
||||
return []
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Starting parallel merge of {len(tasks)} files (max concurrent: {max_concurrent})",
|
||||
)
|
||||
|
||||
# Create semaphore for concurrency control
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
# Create tasks
|
||||
merge_coroutines = [_merge_file_with_ai_async(task, semaphore) for task in tasks]
|
||||
|
||||
# Run all merges concurrently
|
||||
results = await asyncio.gather(*merge_coroutines, return_exceptions=True)
|
||||
|
||||
# Process results, converting exceptions to error results
|
||||
final_results: list[ParallelMergeResult] = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
final_results.append(
|
||||
ParallelMergeResult(
|
||||
file_path=tasks[i].file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error=str(result),
|
||||
)
|
||||
)
|
||||
else:
|
||||
final_results.append(result)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Parallel merge complete: {sum(1 for r in final_results if r.success)} succeeded, "
|
||||
f"{sum(1 for r in final_results if not r.success)} failed",
|
||||
)
|
||||
|
||||
return final_results
|
||||
|
||||
@@ -27,8 +27,7 @@ _spec = importlib.util.spec_from_file_location("workspace_module", _workspace_fi
|
||||
_workspace_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_workspace_module)
|
||||
merge_existing_build = _workspace_module.merge_existing_build
|
||||
# TODO: _run_parallel_merges not yet implemented in workspace.py
|
||||
# _run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
_run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
|
||||
# Models and Enums
|
||||
# Display Functions
|
||||
@@ -105,7 +104,7 @@ from .setup import (
|
||||
__all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
# '_run_parallel_merges', # TODO: not yet implemented - Private but used by tests
|
||||
"_run_parallel_merges", # Private but used internally
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
|
||||
@@ -82,6 +82,7 @@ class LLMProvider(str, Enum):
|
||||
ANTHROPIC = "anthropic"
|
||||
AZURE_OPENAI = "azure_openai"
|
||||
OLLAMA = "ollama"
|
||||
GOOGLE = "google"
|
||||
|
||||
|
||||
class EmbedderProvider(str, Enum):
|
||||
@@ -91,6 +92,7 @@ class EmbedderProvider(str, Enum):
|
||||
VOYAGE = "voyage"
|
||||
AZURE_OPENAI = "azure_openai"
|
||||
OLLAMA = "ollama"
|
||||
GOOGLE = "google"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,6 +130,11 @@ class GraphitiConfig:
|
||||
voyage_api_key: str = ""
|
||||
voyage_embedding_model: str = "voyage-3"
|
||||
|
||||
# Google AI settings (LLM and embeddings)
|
||||
google_api_key: str = ""
|
||||
google_llm_model: str = "gemini-2.0-flash"
|
||||
google_embedding_model: str = "text-embedding-004"
|
||||
|
||||
# Ollama settings (local)
|
||||
ollama_base_url: str = DEFAULT_OLLAMA_BASE_URL
|
||||
ollama_llm_model: str = ""
|
||||
@@ -189,6 +196,13 @@ class GraphitiConfig:
|
||||
voyage_api_key = os.environ.get("VOYAGE_API_KEY", "")
|
||||
voyage_embedding_model = os.environ.get("VOYAGE_EMBEDDING_MODEL", "voyage-3")
|
||||
|
||||
# Google AI settings
|
||||
google_api_key = os.environ.get("GOOGLE_API_KEY", "")
|
||||
google_llm_model = os.environ.get("GOOGLE_LLM_MODEL", "gemini-2.0-flash")
|
||||
google_embedding_model = os.environ.get(
|
||||
"GOOGLE_EMBEDDING_MODEL", "text-embedding-004"
|
||||
)
|
||||
|
||||
# Ollama settings
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL)
|
||||
ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL", "")
|
||||
@@ -220,6 +234,9 @@ class GraphitiConfig:
|
||||
azure_openai_embedding_deployment=azure_openai_embedding_deployment,
|
||||
voyage_api_key=voyage_api_key,
|
||||
voyage_embedding_model=voyage_embedding_model,
|
||||
google_api_key=google_api_key,
|
||||
google_llm_model=google_llm_model,
|
||||
google_embedding_model=google_embedding_model,
|
||||
ollama_base_url=ollama_base_url,
|
||||
ollama_llm_model=ollama_llm_model,
|
||||
ollama_embedding_model=ollama_embedding_model,
|
||||
@@ -262,6 +279,8 @@ class GraphitiConfig:
|
||||
)
|
||||
elif self.llm_provider == "ollama":
|
||||
return bool(self.ollama_llm_model)
|
||||
elif self.llm_provider == "google":
|
||||
return bool(self.google_api_key)
|
||||
return False
|
||||
|
||||
def _validate_embedder_provider(self) -> bool:
|
||||
@@ -278,6 +297,8 @@ class GraphitiConfig:
|
||||
)
|
||||
elif self.embedder_provider == "ollama":
|
||||
return bool(self.ollama_embedding_model and self.ollama_embedding_dim)
|
||||
elif self.embedder_provider == "google":
|
||||
return bool(self.google_api_key)
|
||||
return False
|
||||
|
||||
def get_validation_errors(self) -> list[str]:
|
||||
@@ -309,6 +330,9 @@ class GraphitiConfig:
|
||||
elif self.llm_provider == "ollama":
|
||||
if not self.ollama_llm_model:
|
||||
errors.append("Ollama LLM provider requires OLLAMA_LLM_MODEL")
|
||||
elif self.llm_provider == "google":
|
||||
if not self.google_api_key:
|
||||
errors.append("Google LLM provider requires GOOGLE_API_KEY")
|
||||
else:
|
||||
errors.append(f"Unknown LLM provider: {self.llm_provider}")
|
||||
|
||||
@@ -339,6 +363,9 @@ class GraphitiConfig:
|
||||
)
|
||||
if not self.ollama_embedding_dim:
|
||||
errors.append("Ollama embedder provider requires OLLAMA_EMBEDDING_DIM")
|
||||
elif self.embedder_provider == "google":
|
||||
if not self.google_api_key:
|
||||
errors.append("Google embedder provider requires GOOGLE_API_KEY")
|
||||
else:
|
||||
errors.append(f"Unknown embedder provider: {self.embedder_provider}")
|
||||
|
||||
@@ -517,6 +544,11 @@ def get_available_providers() -> dict:
|
||||
if config.voyage_api_key:
|
||||
available_embedder.append("voyage")
|
||||
|
||||
# Check Google AI
|
||||
if config.google_api_key:
|
||||
available_llm.append("google")
|
||||
available_embedder.append("google")
|
||||
|
||||
# Check Ollama
|
||||
if config.ollama_llm_model:
|
||||
available_llm.append("ollama")
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from graphiti_config import GraphitiConfig
|
||||
|
||||
from .azure_openai_embedder import create_azure_openai_embedder
|
||||
from .google_embedder import create_google_embedder
|
||||
from .ollama_embedder import create_ollama_embedder
|
||||
from .openai_embedder import create_openai_embedder
|
||||
from .voyage_embedder import create_voyage_embedder
|
||||
@@ -20,4 +21,5 @@ __all__ = [
|
||||
"create_voyage_embedder",
|
||||
"create_azure_openai_embedder",
|
||||
"create_ollama_embedder",
|
||||
"create_google_embedder",
|
||||
]
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Google AI Embedder Provider
|
||||
===========================
|
||||
|
||||
Google Gemini embedder implementation for Graphiti.
|
||||
Uses the google-generativeai SDK for text embeddings.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import ProviderError, ProviderNotInstalled
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_config import GraphitiConfig
|
||||
|
||||
|
||||
# Default embedding model for Google
|
||||
DEFAULT_GOOGLE_EMBEDDING_MODEL = "text-embedding-004"
|
||||
|
||||
|
||||
class GoogleEmbedder:
|
||||
"""
|
||||
Google AI Embedder using the Gemini API.
|
||||
|
||||
Implements the EmbedderClient interface expected by graphiti-core.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_GOOGLE_EMBEDDING_MODEL):
|
||||
"""
|
||||
Initialize the Google embedder.
|
||||
|
||||
Args:
|
||||
api_key: Google AI API key
|
||||
model: Embedding model name (default: text-embedding-004)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError as e:
|
||||
raise ProviderNotInstalled(
|
||||
f"Google embedder requires google-generativeai. "
|
||||
f"Install with: pip install google-generativeai\n"
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
# Configure the Google AI client
|
||||
genai.configure(api_key=api_key)
|
||||
self._genai = genai
|
||||
|
||||
async def create(self, input_data: str | list[str]) -> list[float]:
|
||||
"""
|
||||
Create embeddings for the input data.
|
||||
|
||||
Args:
|
||||
input_data: Text string or list of strings to embed
|
||||
|
||||
Returns:
|
||||
List of floats representing the embedding vector
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Handle single string input
|
||||
if isinstance(input_data, str):
|
||||
text = input_data
|
||||
elif isinstance(input_data, list) and len(input_data) > 0:
|
||||
# Join list items if it's a list of strings
|
||||
if isinstance(input_data[0], str):
|
||||
text = " ".join(input_data)
|
||||
else:
|
||||
# It might be token IDs, convert to string
|
||||
text = str(input_data)
|
||||
else:
|
||||
text = str(input_data)
|
||||
|
||||
# Run the synchronous API call in a thread pool
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._genai.embed_content(
|
||||
model=f"models/{self.model}",
|
||||
content=text,
|
||||
task_type="retrieval_document",
|
||||
),
|
||||
)
|
||||
|
||||
return result["embedding"]
|
||||
|
||||
async def create_batch(self, input_data_list: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Create embeddings for a batch of inputs.
|
||||
|
||||
Args:
|
||||
input_data_list: List of text strings to embed
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Google's API supports batch embedding
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Process in batches to avoid rate limits
|
||||
batch_size = 100
|
||||
all_embeddings = []
|
||||
|
||||
for i in range(0, len(input_data_list), batch_size):
|
||||
batch = input_data_list[i : i + batch_size]
|
||||
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda b=batch: self._genai.embed_content(
|
||||
model=f"models/{self.model}",
|
||||
content=b,
|
||||
task_type="retrieval_document",
|
||||
),
|
||||
)
|
||||
|
||||
# Handle single vs batch response
|
||||
if isinstance(result["embedding"][0], list):
|
||||
all_embeddings.extend(result["embedding"])
|
||||
else:
|
||||
all_embeddings.append(result["embedding"])
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
def create_google_embedder(config: "GraphitiConfig") -> Any:
|
||||
"""
|
||||
Create Google AI embedder.
|
||||
|
||||
Args:
|
||||
config: GraphitiConfig with Google settings
|
||||
|
||||
Returns:
|
||||
Google embedder instance
|
||||
|
||||
Raises:
|
||||
ProviderNotInstalled: If google-generativeai is not installed
|
||||
ProviderError: If API key is missing
|
||||
"""
|
||||
if not config.google_api_key:
|
||||
raise ProviderError("Google embedder requires GOOGLE_API_KEY")
|
||||
|
||||
model = config.google_embedding_model or DEFAULT_GOOGLE_EMBEDDING_MODEL
|
||||
|
||||
return GoogleEmbedder(api_key=config.google_api_key, model=model)
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from .embedder_providers import (
|
||||
create_azure_openai_embedder,
|
||||
create_google_embedder,
|
||||
create_ollama_embedder,
|
||||
create_openai_embedder,
|
||||
create_voyage_embedder,
|
||||
@@ -21,6 +22,7 @@ from .exceptions import ProviderError
|
||||
from .llm_providers import (
|
||||
create_anthropic_llm_client,
|
||||
create_azure_openai_llm_client,
|
||||
create_google_llm_client,
|
||||
create_ollama_llm_client,
|
||||
create_openai_llm_client,
|
||||
)
|
||||
@@ -54,6 +56,8 @@ def create_llm_client(config: "GraphitiConfig") -> Any:
|
||||
return create_azure_openai_llm_client(config)
|
||||
elif provider == "ollama":
|
||||
return create_ollama_llm_client(config)
|
||||
elif provider == "google":
|
||||
return create_google_llm_client(config)
|
||||
else:
|
||||
raise ProviderError(f"Unknown LLM provider: {provider}")
|
||||
|
||||
@@ -84,5 +88,7 @@ def create_embedder(config: "GraphitiConfig") -> Any:
|
||||
return create_azure_openai_embedder(config)
|
||||
elif provider == "ollama":
|
||||
return create_ollama_embedder(config)
|
||||
elif provider == "google":
|
||||
return create_google_embedder(config)
|
||||
else:
|
||||
raise ProviderError(f"Unknown embedder provider: {provider}")
|
||||
|
||||
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from .anthropic_llm import create_anthropic_llm_client
|
||||
from .azure_openai_llm import create_azure_openai_llm_client
|
||||
from .google_llm import create_google_llm_client
|
||||
from .ollama_llm import create_ollama_llm_client
|
||||
from .openai_llm import create_openai_llm_client
|
||||
|
||||
@@ -20,4 +21,5 @@ __all__ = [
|
||||
"create_anthropic_llm_client",
|
||||
"create_azure_openai_llm_client",
|
||||
"create_ollama_llm_client",
|
||||
"create_google_llm_client",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Google AI LLM Provider
|
||||
======================
|
||||
|
||||
Google Gemini LLM client implementation for Graphiti.
|
||||
Uses the google-generativeai SDK.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import ProviderError, ProviderNotInstalled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_config import GraphitiConfig
|
||||
|
||||
|
||||
# Default model for Google LLM
|
||||
DEFAULT_GOOGLE_LLM_MODEL = "gemini-2.0-flash"
|
||||
|
||||
|
||||
class GoogleLLMClient:
|
||||
"""
|
||||
Google AI LLM Client using the Gemini API.
|
||||
|
||||
Implements the LLMClient interface expected by graphiti-core.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = DEFAULT_GOOGLE_LLM_MODEL):
|
||||
"""
|
||||
Initialize the Google LLM client.
|
||||
|
||||
Args:
|
||||
api_key: Google AI API key
|
||||
model: Model name (default: gemini-2.0-flash)
|
||||
"""
|
||||
try:
|
||||
import google.generativeai as genai
|
||||
except ImportError as e:
|
||||
raise ProviderNotInstalled(
|
||||
f"Google LLM requires google-generativeai. "
|
||||
f"Install with: pip install google-generativeai\n"
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
# Configure the Google AI client
|
||||
genai.configure(api_key=api_key)
|
||||
self._genai = genai
|
||||
self._model = genai.GenerativeModel(model)
|
||||
|
||||
async def generate_response(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
response_model: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Generate a response from the LLM.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
response_model: Optional Pydantic model for structured output
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
Generated response (string or structured object)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Convert messages to Google format
|
||||
# Google uses 'user' and 'model' roles
|
||||
google_messages = []
|
||||
system_instruction = None
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "system":
|
||||
# Google handles system messages as system_instruction
|
||||
system_instruction = content
|
||||
elif role == "assistant":
|
||||
google_messages.append({"role": "model", "parts": [content]})
|
||||
else:
|
||||
google_messages.append({"role": "user", "parts": [content]})
|
||||
|
||||
# Create model with system instruction if provided
|
||||
if system_instruction:
|
||||
model = self._genai.GenerativeModel(
|
||||
self.model, system_instruction=system_instruction
|
||||
)
|
||||
else:
|
||||
model = self._model
|
||||
|
||||
# Generate response
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if response_model:
|
||||
# For structured output, use JSON mode
|
||||
generation_config = self._genai.GenerationConfig(
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: model.generate_content(
|
||||
google_messages, generation_config=generation_config
|
||||
),
|
||||
)
|
||||
|
||||
# Parse JSON response into the model
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(response.text)
|
||||
return response_model(**data)
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, return raw text
|
||||
logger.warning(
|
||||
"Failed to parse JSON response from Google AI, returning raw text"
|
||||
)
|
||||
return response.text
|
||||
else:
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: model.generate_content(google_messages)
|
||||
)
|
||||
|
||||
return response.text
|
||||
|
||||
async def generate_response_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
Generate a response with tool calling support.
|
||||
|
||||
Note: Tool calling is not yet implemented for Google AI provider.
|
||||
This method will log a warning and fall back to regular generation.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts
|
||||
tools: List of tool definitions
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
Generated response (without tool calls)
|
||||
"""
|
||||
if tools:
|
||||
logger.warning(
|
||||
"Google AI provider does not yet support tool calling. "
|
||||
"Tools will be ignored and regular generation will be used."
|
||||
)
|
||||
return await self.generate_response(messages, **kwargs)
|
||||
|
||||
|
||||
def create_google_llm_client(config: "GraphitiConfig") -> Any:
|
||||
"""
|
||||
Create Google AI LLM client.
|
||||
|
||||
Args:
|
||||
config: GraphitiConfig with Google settings
|
||||
|
||||
Returns:
|
||||
Google LLM client instance
|
||||
|
||||
Raises:
|
||||
ProviderNotInstalled: If google-generativeai is not installed
|
||||
ProviderError: If API key is missing
|
||||
"""
|
||||
if not config.google_api_key:
|
||||
raise ProviderError("Google LLM provider requires GOOGLE_API_KEY")
|
||||
|
||||
model = config.google_llm_model or DEFAULT_GOOGLE_LLM_MODEL
|
||||
|
||||
return GoogleLLMClient(api_key=config.google_api_key, model=model)
|
||||
@@ -193,7 +193,12 @@ class ModificationTracker:
|
||||
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
try:
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
|
||||
@@ -138,6 +138,8 @@ class EvolutionStorage:
|
||||
if baseline_path.exists():
|
||||
try:
|
||||
return baseline_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return baseline_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read baseline {baseline_snapshot_path}: {e}")
|
||||
return None
|
||||
@@ -157,6 +159,8 @@ class EvolutionStorage:
|
||||
if not path.is_absolute():
|
||||
path = self.project_dir / path
|
||||
return path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
@@ -191,7 +191,10 @@ class TimelineGitHelper:
|
||||
|
||||
worktree_path = self.project_path / ".worktrees" / spec_name / file_path
|
||||
if worktree_path.exists():
|
||||
return worktree_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
return worktree_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return worktree_path.read_text(encoding="utf-8", errors="replace")
|
||||
return ""
|
||||
|
||||
def get_changed_files_in_worktree(self, worktree_path: Path) -> list[str]:
|
||||
|
||||
@@ -506,7 +506,12 @@ class FileTimelineTracker:
|
||||
for file_path in changed_files:
|
||||
full_path = worktree_path / file_path
|
||||
if full_path.exists():
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = full_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
self.on_task_worktree_change(task_id, file_path, content)
|
||||
|
||||
debug_success(MODULE, f"Captured {len(changed_files)} files from worktree")
|
||||
|
||||
@@ -7,9 +7,20 @@ Utilities for reading and parsing project configuration files
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
# tomllib is available in Python 3.11+, use tomli for older versions
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
try:
|
||||
import tomli as tomllib
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Python < 3.11 requires 'tomli' package for TOML parsing. "
|
||||
"Install with: pip install tomli"
|
||||
) from None
|
||||
|
||||
|
||||
class ConfigParser:
|
||||
@@ -37,8 +48,13 @@ class ConfigParser:
|
||||
try:
|
||||
with open(self.project_dir / filename, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as e:
|
||||
# Handle both tomllib.TOMLDecodeError and tomli.TOMLDecodeError
|
||||
if "TOMLDecodeError" in type(e).__name__:
|
||||
return None
|
||||
raise
|
||||
|
||||
def read_text(self, filename: str) -> str | None:
|
||||
"""Read a text file from project root."""
|
||||
|
||||
@@ -90,9 +90,9 @@ Find these critical sections:
|
||||
cat project_index.json
|
||||
```
|
||||
|
||||
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT.**
|
||||
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT USING THE WRITE TOOL.**
|
||||
|
||||
Based on your Phase 0 investigation, create `project_index.json`:
|
||||
Based on your Phase 0 investigation, use the Write tool to create `project_index.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -130,9 +130,9 @@ This contains:
|
||||
cat context.json
|
||||
```
|
||||
|
||||
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT.**
|
||||
**IF THIS FILE DOES NOT EXIST, YOU MUST CREATE IT USING THE WRITE TOOL.**
|
||||
|
||||
Based on your Phase 0 investigation and the spec.md, create `context.json`:
|
||||
Based on your Phase 0 investigation and the spec.md, use the Write tool to create `context.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -203,6 +203,15 @@ Minimal overhead - just subtasks, no phases.
|
||||
|
||||
## PHASE 3: CREATE implementation_plan.json
|
||||
|
||||
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
|
||||
|
||||
You MUST use the Write tool to save the implementation plan to `implementation_plan.json`.
|
||||
Do NOT just describe what the file should contain - you must actually call the Write tool with the complete JSON content.
|
||||
|
||||
**Required action:** Call the Write tool with:
|
||||
- file_path: `implementation_plan.json` (in the spec directory)
|
||||
- content: The complete JSON plan structure shown below
|
||||
|
||||
Based on the workflow type and services involved, create the implementation plan.
|
||||
|
||||
### Plan Structure
|
||||
@@ -631,8 +640,26 @@ Include parallelism analysis, verification strategy, and QA configuration in the
|
||||
|
||||
---
|
||||
|
||||
**🚨 END OF PHASE 4 CHECKPOINT 🚨**
|
||||
|
||||
Before proceeding to PHASE 5, verify you have:
|
||||
1. ✅ Created the complete implementation_plan.json structure
|
||||
2. ✅ Used the Write tool to save it (not just described it)
|
||||
3. ✅ Added the summary section with parallelism analysis
|
||||
4. ✅ Added the verification_strategy section
|
||||
5. ✅ Added the qa_acceptance section
|
||||
|
||||
If you have NOT used the Write tool yet, STOP and do it now!
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5: CREATE init.sh
|
||||
|
||||
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
|
||||
|
||||
You MUST use the Write tool to save the init.sh script.
|
||||
Do NOT just describe what the file should contain - you must actually call the Write tool.
|
||||
|
||||
Create a setup script based on `project_index.json`:
|
||||
|
||||
```bash
|
||||
@@ -735,6 +762,11 @@ Note: If the commit fails (e.g., nothing to commit, or in a special workspace),
|
||||
|
||||
## PHASE 7: CREATE build-progress.txt
|
||||
|
||||
**🚨 CRITICAL: YOU MUST USE THE WRITE TOOL TO CREATE THIS FILE 🚨**
|
||||
|
||||
You MUST use the Write tool to save build-progress.txt.
|
||||
Do NOT just describe what the file should contain - you must actually call the Write tool with the complete content shown below.
|
||||
|
||||
```
|
||||
=== AUTO-BUILD PROGRESS ===
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
# Directory containing prompt files
|
||||
PROMPTS_DIR = Path(__file__).parent / "prompts"
|
||||
# prompts/ is a sibling directory of prompts_pkg/, so go up one level first
|
||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||
|
||||
|
||||
def get_planner_prompt(spec_dir: Path) -> str:
|
||||
@@ -38,10 +39,15 @@ def get_planner_prompt(spec_dir: Path) -> str:
|
||||
|
||||
Your spec file is located at: `{spec_dir}/spec.md`
|
||||
|
||||
Store all build artifacts in this spec directory:
|
||||
- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan
|
||||
- `{spec_dir}/build-progress.txt` - Progress notes
|
||||
- `{spec_dir}/init.sh` - Environment setup script
|
||||
🚨 CRITICAL FILE CREATION INSTRUCTIONS 🚨
|
||||
|
||||
You MUST use the Write tool to create these files in the spec directory:
|
||||
- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan (USE WRITE TOOL!)
|
||||
- `{spec_dir}/build-progress.txt` - Progress notes (USE WRITE TOOL!)
|
||||
- `{spec_dir}/init.sh` - Environment setup script (USE WRITE TOOL!)
|
||||
|
||||
DO NOT just describe what these files should contain. You MUST actually call the Write tool
|
||||
with the file path and complete content to create them.
|
||||
|
||||
The project root is the parent of auto-claude/. Implement code in the project root, not in the spec directory.
|
||||
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
claude-agent-sdk>=0.1.16
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# TOML parsing fallback for Python < 3.11
|
||||
tomli>=2.0.0; python_version < "3.11"
|
||||
|
||||
# Memory Integration (highly recommended) but can be disabled by commenting out the line below
|
||||
graphiti-core[falkordb]>=0.5.0
|
||||
|
||||
# Google AI (optional - for Gemini LLM and embeddings)
|
||||
google-generativeai>=0.8.0
|
||||
|
||||
@@ -331,6 +331,9 @@ def main():
|
||||
parser.add_argument("--project-dir", required=True, help="Project directory path")
|
||||
parser.add_argument("--message", required=True, help="User message")
|
||||
parser.add_argument("--history", default="[]", help="JSON conversation history")
|
||||
parser.add_argument(
|
||||
"--history-file", help="Path to JSON file containing conversation history"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="claude-sonnet-4-5-20250929",
|
||||
@@ -360,11 +363,26 @@ def main():
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
|
||||
# Load history from file if provided, otherwise parse inline JSON
|
||||
try:
|
||||
history = json.loads(args.history)
|
||||
debug_detailed("insights_runner", "Parsed history", history_length=len(history))
|
||||
except json.JSONDecodeError:
|
||||
debug_error("insights_runner", "Failed to parse history JSON")
|
||||
if args.history_file:
|
||||
debug(
|
||||
"insights_runner", "Loading history from file", file=args.history_file
|
||||
)
|
||||
with open(args.history_file, encoding="utf-8") as f:
|
||||
history = json.load(f)
|
||||
debug_detailed(
|
||||
"insights_runner",
|
||||
"Loaded history from file",
|
||||
history_length=len(history),
|
||||
)
|
||||
else:
|
||||
history = json.loads(args.history)
|
||||
debug_detailed(
|
||||
"insights_runner", "Parsed inline history", history_length=len(history)
|
||||
)
|
||||
except (json.JSONDecodeError, FileNotFoundError, OSError) as e:
|
||||
debug_error("insights_runner", f"Failed to load history: {e}")
|
||||
history = []
|
||||
|
||||
# Run the async SDK function
|
||||
|
||||
@@ -21,9 +21,7 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude"))
|
||||
|
||||
from workspace import ParallelMergeTask, ParallelMergeResult
|
||||
|
||||
# _run_parallel_merges is not yet implemented - tests that use it are skipped
|
||||
_run_parallel_merges = None
|
||||
from core.workspace import _run_parallel_merges
|
||||
|
||||
|
||||
class TestParallelMergeDataclasses:
|
||||
@@ -101,11 +99,10 @@ class TestParallelMergeDataclasses:
|
||||
class TestParallelMergeRunner:
|
||||
"""Tests for the parallel merge runner."""
|
||||
|
||||
@pytest.mark.skip(reason="_run_parallel_merges not yet implemented")
|
||||
def test_run_parallel_merges_empty_list(self, project_dir):
|
||||
def test_run_parallel_merges_empty_list(self, tmp_path):
|
||||
"""Running with empty task list returns empty results."""
|
||||
import asyncio
|
||||
results = asyncio.run(_run_parallel_merges([], project_dir))
|
||||
results = asyncio.run(_run_parallel_merges([], tmp_path))
|
||||
assert results == []
|
||||
|
||||
def test_parallel_merge_task_with_data(self):
|
||||
@@ -123,6 +120,81 @@ class TestParallelMergeRunner:
|
||||
assert task.spec_name == "001-feature"
|
||||
|
||||
|
||||
class TestSimple3WayMerge:
|
||||
"""Tests for the simple 3-way merge logic."""
|
||||
|
||||
def test_identical_files_merge(self, tmp_path):
|
||||
"""When both versions are identical, return that version."""
|
||||
import asyncio
|
||||
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/test.py",
|
||||
main_content="def main(): pass",
|
||||
worktree_content="def main(): pass", # Same as main
|
||||
base_content="def main(): pass", # Same as both
|
||||
spec_name="001-no-change",
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
assert results[0].was_auto_merged is True
|
||||
assert results[0].merged_content == "def main(): pass"
|
||||
|
||||
def test_only_worktree_changed(self, tmp_path):
|
||||
"""When only worktree changed, take worktree version."""
|
||||
import asyncio
|
||||
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/test.py",
|
||||
main_content="def main(): pass", # Same as base
|
||||
worktree_content="def main():\n print('new')", # Changed
|
||||
base_content="def main(): pass",
|
||||
spec_name="001-worktree-only",
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
assert results[0].was_auto_merged is True
|
||||
assert "print('new')" in results[0].merged_content
|
||||
|
||||
def test_only_main_changed(self, tmp_path):
|
||||
"""When only main changed, take main version."""
|
||||
import asyncio
|
||||
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/test.py",
|
||||
main_content="def main():\n print('main')", # Changed
|
||||
worktree_content="def main(): pass", # Same as base
|
||||
base_content="def main(): pass",
|
||||
spec_name="001-main-only",
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
assert results[0].was_auto_merged is True
|
||||
assert "print('main')" in results[0].merged_content
|
||||
|
||||
def test_no_base_but_identical(self, tmp_path):
|
||||
"""When no base and both identical, return that version."""
|
||||
import asyncio
|
||||
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/new.py",
|
||||
main_content="# Same content",
|
||||
worktree_content="# Same content",
|
||||
base_content=None, # New file, no base
|
||||
spec_name="001-new-identical",
|
||||
)
|
||||
|
||||
results = asyncio.run(_run_parallel_merges([task], tmp_path))
|
||||
assert len(results) == 1
|
||||
assert results[0].success is True
|
||||
assert results[0].was_auto_merged is True
|
||||
|
||||
|
||||
class TestParallelMergeIntegration:
|
||||
"""Integration tests for parallel merge flow."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user