mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Compare commits
143 Commits
claude/add
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2f277f63 | ||
|
|
ef3f8de33b | ||
|
|
d379bf412a | ||
|
|
cf35ca8650 | ||
|
|
4f1555f196 | ||
|
|
5aace0ce0f | ||
|
|
e439d8a632 | ||
|
|
b7c6b8bfc6 | ||
|
|
a60904bd51 | ||
|
|
d7c3337330 | ||
|
|
79236ba16e | ||
|
|
c848306e4c | ||
|
|
f0042312d0 | ||
|
|
e876d177b8 | ||
|
|
8caec15199 | ||
|
|
7fe9aacb09 | ||
|
|
f55c985634 | ||
|
|
38e8a4c4ea | ||
|
|
f3ce5ce8ab | ||
|
|
99de7813c9 | ||
|
|
2de3ae69d4 | ||
|
|
0b4e9573ed | ||
|
|
d7ad87bd1b | ||
|
|
615823652c | ||
|
|
2f883bad20 | ||
|
|
45706990df | ||
|
|
c9c406dd21 | ||
|
|
014736bc1d | ||
|
|
c05359c787 | ||
|
|
a32cb08d1e | ||
|
|
08d1497cbe | ||
|
|
5c335641fa | ||
|
|
0fb471ca15 | ||
|
|
b65037d995 | ||
|
|
5eda2c9b2b | ||
|
|
006152554b | ||
|
|
3b56d553c9 | ||
|
|
375f9ea9d4 | ||
|
|
bf25a7a4e5 | ||
|
|
5171abc37f | ||
|
|
9c8265c4e5 | ||
|
|
ef779daedf | ||
|
|
011ac404bb | ||
|
|
9587f13de5 | ||
|
|
08dc90b378 | ||
|
|
80ef21c8d0 | ||
|
|
98d98cc056 | ||
|
|
2a24377870 | ||
|
|
895e4c28ba | ||
|
|
ebf2fcadd6 | ||
|
|
019da6b77a | ||
|
|
605d9658d9 | ||
|
|
906f471521 | ||
|
|
a10ddadbde | ||
|
|
3399d48823 | ||
|
|
7f5c5e864d | ||
|
|
35d2d41821 | ||
|
|
6a3993385e | ||
|
|
df7024f4ea | ||
|
|
4485c49c9b | ||
|
|
7a5cb38a37 | ||
|
|
c9833b67a0 | ||
|
|
0f11ee2212 | ||
|
|
74b301c2d1 | ||
|
|
81ee2d1399 | ||
|
|
f025ced035 | ||
|
|
4f07948712 | ||
|
|
07f95ae13b | ||
|
|
8dd6ab2161 | ||
|
|
b5143f4b00 | ||
|
|
f5efa857ca | ||
|
|
c401bf4e63 | ||
|
|
43d5ec9aed | ||
|
|
f8108b1a6c | ||
|
|
076ab14a5e | ||
|
|
a4c43b99a5 | ||
|
|
0f00180c50 | ||
|
|
22853c988a | ||
|
|
e52837cbe7 | ||
|
|
d12e0705f0 | ||
|
|
a3e536b8e6 | ||
|
|
43661e5a6e | ||
|
|
1b2bf0df3f | ||
|
|
b1060c6a11 | ||
|
|
db87e83aed | ||
|
|
92b1fb3725 | ||
|
|
d7f86d142a | ||
|
|
bbe669cdf2 | ||
|
|
8e13245aab | ||
|
|
cec5f91a86 | ||
|
|
ed92d4fd80 | ||
|
|
a6190f71b3 | ||
|
|
d04934359a | ||
|
|
7246debb69 | ||
|
|
066ffe5639 | ||
|
|
7bf02b64fa | ||
|
|
a3c62e8358 | ||
|
|
1ecb97b71c | ||
|
|
1e87b73dfd | ||
|
|
5a3dac1533 | ||
|
|
f3b16ad8ce | ||
|
|
140c444e6f | ||
|
|
907c1d65b3 | ||
|
|
92f2702f3b | ||
|
|
735786701f | ||
|
|
900bbb5e80 | ||
|
|
bc3e3dad1c | ||
|
|
d8fa5c4cd1 | ||
|
|
f005c30017 | ||
|
|
4012a2964a | ||
|
|
0b92349890 | ||
|
|
51a75ae589 | ||
|
|
650edd69ca | ||
|
|
46abd34444 | ||
|
|
5cf817e9de | ||
|
|
42ee4f211d | ||
|
|
372cfe6982 | ||
|
|
1430fb6926 | ||
|
|
9e15f3609a | ||
|
|
b34ffd9565 | ||
|
|
ac9f33bd2b | ||
|
|
269b1c9478 | ||
|
|
7bc7918cc6 | ||
|
|
860d6836b9 | ||
|
|
5281b81ddf | ||
|
|
7a33940816 | ||
|
|
ee4464bdad | ||
|
|
7e1095b773 | ||
|
|
9d297c650a | ||
|
|
68d78f2f5b | ||
|
|
fb6d6bbf2f | ||
|
|
c8ed3fafce | ||
|
|
5939c5d20b | ||
|
|
ad6fc01045 | ||
|
|
ea34f304cb | ||
|
|
53ad78dfc8 | ||
|
|
26b819291f | ||
|
|
a4214276d7 | ||
|
|
d09da4af20 | ||
|
|
433e6016c3 | ||
|
|
02dfda108e | ||
|
|
c55654b737 | ||
|
|
927ce9121d |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -65,6 +68,7 @@ jobs:
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -73,6 +77,7 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
@@ -84,6 +89,7 @@ jobs:
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
@@ -113,15 +119,13 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/macos-builds/*.blockmap
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/windows-builds/*.blockmap
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
artifacts/linux-builds/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -25,6 +25,7 @@ COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
|
||||
300
SECURITY_TODO.md
300
SECURITY_TODO.md
@@ -1,300 +0,0 @@
|
||||
# Security Audit Findings - v0.13.0rc Branch
|
||||
|
||||
**Date:** $(date)
|
||||
**Audit Type:** Git diff security review against v0.13.0rc branch
|
||||
**Status:** ⚠️ Security vulnerabilities found - requires fixes before release
|
||||
|
||||
## Executive Summary
|
||||
|
||||
No intentionally malicious code was detected in the changes. However, several **critical security vulnerabilities** were identified that could allow command injection attacks. These must be fixed before release.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Security Issues
|
||||
|
||||
### 1. Command Injection in Merge Handler
|
||||
|
||||
**File:** `apps/server/src/routes/worktree/routes/merge.ts`
|
||||
**Lines:** 43, 54, 65-66, 93
|
||||
**Severity:** CRITICAL
|
||||
|
||||
**Issue:**
|
||||
User-controlled inputs (`branchName`, `mergeTo`, `options?.message`) are directly interpolated into shell commands without validation, allowing command injection attacks.
|
||||
|
||||
**Vulnerable Code:**
|
||||
|
||||
```typescript
|
||||
// Line 43 - branchName not validated
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
|
||||
// Line 54 - mergeTo not validated
|
||||
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
|
||||
|
||||
// Lines 65-66 - branchName and message not validated
|
||||
const mergeCmd = options?.squash
|
||||
? `git merge --squash ${branchName}`
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
||||
|
||||
// Line 93 - message not sanitized
|
||||
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
```
|
||||
|
||||
**Attack Vector:**
|
||||
An attacker could inject shell commands via branch names or commit messages:
|
||||
|
||||
- Branch name: `main; rm -rf /`
|
||||
- Commit message: `"; malicious_command; "`
|
||||
|
||||
**Fix Required:**
|
||||
|
||||
1. Validate `branchName` and `mergeTo` using `isValidBranchName()` before use
|
||||
2. Sanitize commit messages or use `execGitCommand` with proper escaping
|
||||
3. Replace `execAsync` template literals with `execGitCommand` array-based calls
|
||||
|
||||
**Note:** `isValidBranchName` is imported but only used AFTER deletion (line 119), not before execAsync calls.
|
||||
|
||||
---
|
||||
|
||||
### 2. Command Injection in Push Handler
|
||||
|
||||
**File:** `apps/server/src/routes/worktree/routes/push.ts`
|
||||
**Lines:** 44, 49
|
||||
**Severity:** CRITICAL
|
||||
|
||||
**Issue:**
|
||||
User-controlled `remote` parameter and `branchName` are directly interpolated into shell commands without validation.
|
||||
|
||||
**Vulnerable Code:**
|
||||
|
||||
```typescript
|
||||
// Line 38 - remote defaults to 'origin' but not validated
|
||||
const targetRemote = remote || 'origin';
|
||||
|
||||
// Lines 44, 49 - targetRemote and branchName not validated
|
||||
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
```
|
||||
|
||||
**Attack Vector:**
|
||||
An attacker could inject commands via the remote name:
|
||||
|
||||
- Remote: `origin; malicious_command; #`
|
||||
|
||||
**Fix Required:**
|
||||
|
||||
1. Validate `targetRemote` parameter (alphanumeric + `-`, `_` only)
|
||||
2. Validate `branchName` before use (even though it comes from git output)
|
||||
3. Use `execGitCommand` with array arguments instead of template literals
|
||||
|
||||
---
|
||||
|
||||
### 3. Unsafe Environment Variable Export in Shell Script
|
||||
|
||||
**File:** `start-automaker.sh`
|
||||
**Lines:** 5068, 5085
|
||||
**Severity:** CRITICAL
|
||||
|
||||
**Issue:**
|
||||
Unsafe parsing and export of `.env` file contents using `xargs` without proper handling of special characters.
|
||||
|
||||
**Vulnerable Code:**
|
||||
|
||||
```bash
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
```
|
||||
|
||||
**Attack Vector:**
|
||||
If `.env` file contains malicious content with spaces, special characters, or code, it could be executed:
|
||||
|
||||
- `.env` entry: `VAR="value; malicious_command"`
|
||||
- Could lead to code execution during startup
|
||||
|
||||
**Fix Required:**
|
||||
Replace with safer parsing method:
|
||||
|
||||
```bash
|
||||
# Safer approach
|
||||
set -a
|
||||
source <(grep -v '^#' .env | sed 's/^/export /')
|
||||
set +a
|
||||
|
||||
# Or even safer - validate each line
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "$line" ]] && continue
|
||||
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
export "${BASH_REMATCH[1]}"="${BASH_REMATCH[2]}"
|
||||
fi
|
||||
done < .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Moderate Security Concerns
|
||||
|
||||
### 4. Inconsistent Use of Secure Command Execution
|
||||
|
||||
**Issue:**
|
||||
The codebase has `execGitCommand()` function available (which uses array arguments and is safer), but it's not consistently used. Some places still use `execAsync` with template literals.
|
||||
|
||||
**Files Affected:**
|
||||
|
||||
- `apps/server/src/routes/worktree/routes/merge.ts`
|
||||
- `apps/server/src/routes/worktree/routes/push.ts`
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Audit all `execAsync` calls with template literals
|
||||
- Replace with `execGitCommand` where possible
|
||||
- Document when `execAsync` is acceptable (only with fully validated inputs)
|
||||
|
||||
---
|
||||
|
||||
### 5. Missing Input Validation
|
||||
|
||||
**Issues:**
|
||||
|
||||
1. `targetRemote` in `push.ts` defaults to 'origin' but isn't validated
|
||||
2. Commit messages in `merge.ts` aren't sanitized before use in shell commands
|
||||
3. `worktreePath` validation relies on middleware but should be double-checked
|
||||
|
||||
**Recommendation:**
|
||||
|
||||
- Add validation functions for remote names
|
||||
- Sanitize commit messages (remove shell metacharacters)
|
||||
- Add defensive validation even when middleware exists
|
||||
|
||||
---
|
||||
|
||||
## ✅ Positive Security Findings
|
||||
|
||||
1. **No Hardcoded Credentials:** No API keys, passwords, or tokens found in the diff
|
||||
2. **No Data Exfiltration:** No suspicious network requests or data transmission patterns
|
||||
3. **No Backdoors:** No hidden functionality or unauthorized access patterns detected
|
||||
4. **Safe Command Execution:** `execGitCommand` function properly uses array arguments in some places
|
||||
5. **Environment Variable Handling:** `init-script-service.ts` properly sanitizes environment variables (lines 194-220)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Action Items
|
||||
|
||||
### Immediate (Before Release)
|
||||
|
||||
- [ ] **Fix command injection in `merge.ts`**
|
||||
- [ ] Validate `branchName` with `isValidBranchName()` before line 43
|
||||
- [ ] Validate `mergeTo` with `isValidBranchName()` before line 54
|
||||
- [ ] Sanitize commit messages or use `execGitCommand` for merge commands
|
||||
- [ ] Replace `execAsync` template literals with `execGitCommand` array calls
|
||||
|
||||
- [ ] **Fix command injection in `push.ts`**
|
||||
- [ ] Add validation function for remote names
|
||||
- [ ] Validate `targetRemote` before use
|
||||
- [ ] Validate `branchName` before use (defensive programming)
|
||||
- [ ] Replace `execAsync` template literals with `execGitCommand`
|
||||
|
||||
- [ ] **Fix shell script security issue**
|
||||
- [ ] Replace unsafe `export $(grep ... | xargs)` with safer parsing
|
||||
- [ ] Add validation for `.env` file contents
|
||||
- [ ] Test with edge cases (spaces, special chars, quotes)
|
||||
|
||||
### Short-term (Next Sprint)
|
||||
|
||||
- [ ] **Audit all `execAsync` calls**
|
||||
- [ ] Create inventory of all `execAsync` calls with template literals
|
||||
- [ ] Replace with `execGitCommand` where possible
|
||||
- [ ] Document exceptions and why they're safe
|
||||
|
||||
- [ ] **Add input validation utilities**
|
||||
- [ ] Create `isValidRemoteName()` function
|
||||
- [ ] Create `sanitizeCommitMessage()` function
|
||||
- [ ] Add validation for all user-controlled inputs
|
||||
|
||||
- [ ] **Security testing**
|
||||
- [ ] Add unit tests for command injection prevention
|
||||
- [ ] Add integration tests with malicious inputs
|
||||
- [ ] Test shell script with malicious `.env` files
|
||||
|
||||
### Long-term (Security Hardening)
|
||||
|
||||
- [ ] **Code review process**
|
||||
- [ ] Add security checklist for PR reviews
|
||||
- [ ] Require security review for shell command execution changes
|
||||
- [ ] Add automated security scanning
|
||||
|
||||
- [ ] **Documentation**
|
||||
- [ ] Document secure coding practices for shell commands
|
||||
- [ ] Create security guidelines for contributors
|
||||
- [ ] Add security section to CONTRIBUTING.md
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Testing Recommendations
|
||||
|
||||
### Command Injection Tests
|
||||
|
||||
```typescript
|
||||
// Test cases for merge.ts
|
||||
describe('merge handler security', () => {
|
||||
it('should reject branch names with shell metacharacters', () => {
|
||||
// Test: branchName = "main; rm -rf /"
|
||||
// Expected: Validation error, command not executed
|
||||
});
|
||||
|
||||
it('should sanitize commit messages', () => {
|
||||
// Test: message = '"; malicious_command; "'
|
||||
// Expected: Sanitized or rejected
|
||||
});
|
||||
});
|
||||
|
||||
// Test cases for push.ts
|
||||
describe('push handler security', () => {
|
||||
it('should reject remote names with shell metacharacters', () => {
|
||||
// Test: remote = "origin; malicious_command; #"
|
||||
// Expected: Validation error, command not executed
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Shell Script Tests
|
||||
|
||||
```bash
|
||||
# Test with malicious .env content
|
||||
echo 'VAR="value; echo PWNED"' > test.env
|
||||
# Expected: Should not execute the command
|
||||
|
||||
# Test with spaces in values
|
||||
echo 'VAR="value with spaces"' > test.env
|
||||
# Expected: Should handle correctly
|
||||
|
||||
# Test with special characters
|
||||
echo 'VAR="value\$with\$dollars"' > test.env
|
||||
# Expected: Should handle correctly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [OWASP Command Injection](https://owasp.org/www-community/attacks/Command_Injection)
|
||||
- [Node.js Child Process Security](https://nodejs.org/api/child_process.html#child_process_security_concerns)
|
||||
- [Shell Script Security Best Practices](https://mywiki.wooledge.org/BashGuide/Practices)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All findings are based on code diff analysis
|
||||
- No runtime testing was performed
|
||||
- Assumes attacker has access to API endpoints (authenticated or unauthenticated)
|
||||
- Fixes should be tested thoroughly before deployment
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** $(date)
|
||||
**Next Review:** After fixes are implemented
|
||||
25
TODO.md
25
TODO.md
@@ -1,25 +0,0 @@
|
||||
# Bugs
|
||||
|
||||
- Setting the default model does not seem like it works.
|
||||
|
||||
# Performance (completed)
|
||||
|
||||
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
|
||||
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
|
||||
- [x] Reduce blur/shadow effects when lists get large
|
||||
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
|
||||
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
|
||||
|
||||
# UX
|
||||
|
||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
||||
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
|
||||
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
|
||||
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
|
||||
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
|
||||
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
|
||||
- Typing in the text area of the plan mode was super laggy.
|
||||
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
|
||||
- modals are not scrollable if height of the screen is small enough
|
||||
- and the Agent Runner add an archival button for the new sessions.
|
||||
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.
|
||||
@@ -32,6 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Server');
|
||||
@@ -83,8 +83,7 @@ import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||
import { createProviderUsageRoutes } from './routes/provider-usage/index.js';
|
||||
import { ProviderUsageTracker } from './services/provider-usage-tracker.js';
|
||||
import { createProjectsRoutes } from './routes/projects/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -118,15 +117,44 @@ export function isRequestLoggingEnabled(): boolean {
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
// Check for Claude authentication (async - runs in background)
|
||||
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
|
||||
(async () => {
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
if (hasAnthropicKey) {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Claude Code CLI authentication
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
const hasCliAuth =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
(indicators.hasCredentialsFile &&
|
||||
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
|
||||
|
||||
if (hasCliAuth) {
|
||||
logger.info('✓ Claude Code CLI authentication detected');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors checking CLI auth - will fall through to warning
|
||||
logger.warn('Error checking for Claude Code CLI authentication:', error);
|
||||
}
|
||||
|
||||
// No authentication found - show warning
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
@@ -139,14 +167,13 @@ if (!hasAnthropicKey) {
|
||||
║ ║
|
||||
║ ${w2}║
|
||||
║ ${w3}║
|
||||
║ ║
|
||||
║ ${w4}║
|
||||
║ ${w5}║
|
||||
║ ${w6}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
}
|
||||
})();
|
||||
|
||||
// Initialize security
|
||||
initAllowedPaths();
|
||||
@@ -238,7 +265,6 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
@@ -328,7 +354,10 @@ app.get('/api/health/detailed', createDetailedHandler());
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
|
||||
app.use(
|
||||
'/api/features',
|
||||
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
@@ -350,7 +379,10 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
|
||||
app.use(
|
||||
'/api/projects',
|
||||
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -768,21 +800,36 @@ process.on('uncaughtException', (error: Error) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
// Graceful shutdown timeout (30 seconds)
|
||||
const SHUTDOWN_TIMEOUT_MS = 30000;
|
||||
|
||||
// Graceful shutdown helper
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down...`);
|
||||
|
||||
// Set up a force-exit timeout to prevent hanging
|
||||
const forceExitTimeout = setTimeout(() => {
|
||||
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
|
||||
// Mark all running features as interrupted before shutdown
|
||||
// This ensures they can be resumed when the server restarts
|
||||
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
|
||||
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
|
||||
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
clearTimeout(forceExitTimeout);
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
gracefulShutdown('SIGTERM');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
gracefulShutdown('SIGINT');
|
||||
});
|
||||
|
||||
@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
*
|
||||
* For feature generation (which can generate 50+ features), we use a much longer
|
||||
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
||||
//
|
||||
// For feature generation with 'xhigh', use the extended 5-minute base timeout
|
||||
// since generating 50+ features takes significantly longer than normal operations.
|
||||
const baseTimeout =
|
||||
options.reasoningEffort === 'xhigh'
|
||||
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
|
||||
: CODEX_CLI_TIMEOUT_MS;
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
|
||||
942
apps/server/src/providers/copilot-provider.ts
Normal file
942
apps/server/src/providers/copilot-provider.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Copilot Provider - Executes queries using the GitHub Copilot SDK
|
||||
*
|
||||
* Uses the official @github/copilot-sdk for:
|
||||
* - Session management and streaming responses
|
||||
* - GitHub OAuth authentication (via gh CLI)
|
||||
* - Tool call handling and permission management
|
||||
* - Runtime model discovery
|
||||
*
|
||||
* Based on https://github.com/github/copilot-sdk
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
// Note: validateBareModelId is not used because Copilot's bare model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt-
|
||||
import {
|
||||
COPILOT_MODEL_MAP,
|
||||
type CopilotAuthStatus,
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
normalizeFilePathInput,
|
||||
normalizeCommandInput,
|
||||
normalizePatternInput,
|
||||
} from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* SDK session event data types
|
||||
*/
|
||||
interface SdkEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SdkMessageEvent extends SdkEvent {
|
||||
type: 'assistant.message';
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
|
||||
// The final assistant.message event contains the complete content
|
||||
|
||||
interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
type: 'tool.execution_start';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkSessionIdleEvent extends SdkEvent {
|
||||
type: 'session.idle';
|
||||
}
|
||||
|
||||
interface SdkSessionErrorEvent extends SdkEvent {
|
||||
type: 'session.error';
|
||||
data: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum CopilotErrorCode {
|
||||
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
|
||||
TIMEOUT = 'COPILOT_TIMEOUT',
|
||||
CLI_ERROR = 'COPILOT_CLI_ERROR',
|
||||
SDK_ERROR = 'COPILOT_SDK_ERROR',
|
||||
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CopilotError extends Error {
|
||||
code: CopilotErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Copilot SDK tool name to standard tool name mapping
|
||||
*
|
||||
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
|
||||
* Tool names are case-insensitive (normalized to lowercase before lookup).
|
||||
*/
|
||||
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
|
||||
// File operations
|
||||
read_file: 'Read',
|
||||
read: 'Read',
|
||||
view: 'Read', // Copilot uses 'view' for reading files
|
||||
read_many_files: 'Read',
|
||||
write_file: 'Write',
|
||||
write: 'Write',
|
||||
create_file: 'Write',
|
||||
edit_file: 'Edit',
|
||||
edit: 'Edit',
|
||||
replace: 'Edit',
|
||||
patch: 'Edit',
|
||||
// Shell operations
|
||||
run_shell: 'Bash',
|
||||
run_shell_command: 'Bash',
|
||||
shell: 'Bash',
|
||||
bash: 'Bash',
|
||||
execute: 'Bash',
|
||||
terminal: 'Bash',
|
||||
// Search operations
|
||||
search: 'Grep',
|
||||
grep: 'Grep',
|
||||
search_file_content: 'Grep',
|
||||
find_files: 'Glob',
|
||||
glob: 'Glob',
|
||||
list_dir: 'Ls',
|
||||
list_directory: 'Ls',
|
||||
ls: 'Ls',
|
||||
// Web operations
|
||||
web_fetch: 'WebFetch',
|
||||
fetch: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
search_web: 'WebSearch',
|
||||
google_web_search: 'WebSearch',
|
||||
// Todo operations
|
||||
todo_write: 'TodoWrite',
|
||||
write_todos: 'TodoWrite',
|
||||
update_todos: 'TodoWrite',
|
||||
// Planning/intent operations (Copilot-specific)
|
||||
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
|
||||
think: 'Think',
|
||||
plan: 'Plan',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool names to standard tool names
|
||||
*/
|
||||
function normalizeCopilotToolName(copilotToolName: string): string {
|
||||
const lowerName = copilotToolName.toLowerCase();
|
||||
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool input parameters to standard format
|
||||
*
|
||||
* Maps Copilot's parameter names to our standard parameter names.
|
||||
* Uses shared utilities from tool-normalization.ts for common normalizations.
|
||||
*/
|
||||
function normalizeCopilotToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalizedName = normalizeCopilotToolName(toolName);
|
||||
|
||||
// Normalize todo_write / write_todos: ensure proper format
|
||||
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
|
||||
// Normalize file path parameters for Read/Write/Edit tools
|
||||
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
|
||||
return normalizeFilePathInput(input);
|
||||
}
|
||||
|
||||
// Normalize shell command parameters for Bash tool
|
||||
if (normalizedName === 'Bash') {
|
||||
return normalizeCommandInput(input);
|
||||
}
|
||||
|
||||
// Normalize search parameters for Grep tool
|
||||
if (normalizedName === 'Grep') {
|
||||
return normalizePatternInput(input);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - GitHub OAuth authentication
|
||||
* - SDK-based session management
|
||||
* - Runtime model discovery
|
||||
* - Tool call normalization
|
||||
* - Per-execution working directory support
|
||||
*/
|
||||
export class CopilotProvider extends CliProvider {
|
||||
private runtimeModels: CopilotRuntimeModel[] | null = null;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Copilot CLI can be run via npx
|
||||
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
'/opt/homebrew/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*
|
||||
* Note: CopilotProvider does not yet support vision/image inputs.
|
||||
* If non-text content is provided, an error is thrown.
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
// Check for non-text content (images, etc.) which we don't support yet
|
||||
const hasNonText = options.prompt.some((p) => p.type !== 'text');
|
||||
if (hasNonText) {
|
||||
throw new Error(
|
||||
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
|
||||
'Please use text-only prompts or switch to a provider that supports vision.'
|
||||
);
|
||||
}
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used with SDK approach - kept for interface compatibility
|
||||
*/
|
||||
buildCliArgs(_options: ExecuteOptions): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const sdkEvent = event as SdkEvent;
|
||||
|
||||
switch (sdkEvent.type) {
|
||||
case 'assistant.message': {
|
||||
const messageEvent = sdkEvent as SdkMessageEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.data.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'assistant.message_delta': {
|
||||
// Skip delta events - they create too much noise
|
||||
// The final assistant.message event has the complete content
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool.execution_start': {
|
||||
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
|
||||
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
|
||||
const normalizedInput = toolEvent.data.input
|
||||
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
|
||||
: {};
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.data.toolCallId,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.data.toolCallId,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.idle': {
|
||||
logger.debug('Copilot session idle');
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Copilot-specific error codes
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('authentication required') ||
|
||||
lower.includes('github login')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'GitHub Copilot is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.RATE_LIMITED,
|
||||
message: 'Copilot API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NETWORK_ERROR,
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: CopilotErrorCode.PROCESS_CRASHED,
|
||||
message: 'Copilot CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: CopilotErrorCode.UNKNOWN,
|
||||
message: stderr || `Copilot CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Copilot-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Copilot SDK with real-time streaming
|
||||
*
|
||||
* Creates a new CopilotClient for each execution with the correct working directory.
|
||||
* Streams tool execution events in real-time for UI display.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Note: We don't use validateBareModelId here because Copilot's model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
|
||||
// actual model names from the Copilot CLI. We only need to ensure the
|
||||
// copilot- prefix has been stripped by the ProviderFactory.
|
||||
if (options.model?.startsWith('copilot-')) {
|
||||
throw new Error(
|
||||
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
|
||||
`The ProviderFactory should strip this prefix before passing to the provider.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CopilotErrorCode.NOT_INSTALLED,
|
||||
'Copilot CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
|
||||
);
|
||||
logger.debug(`Prompt length: ${promptText.length} characters`);
|
||||
|
||||
// Create a client for this execution with the correct working directory
|
||||
const client = new CopilotClient({
|
||||
logLevel: 'warning',
|
||||
autoRestart: false,
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
|
||||
// Use an async queue to bridge callback-based SDK events to async generator
|
||||
const eventQueue: SdkEvent[] = [];
|
||||
let resolveWaiting: (() => void) | null = null;
|
||||
let sessionComplete = false;
|
||||
let sessionError: Error | null = null;
|
||||
|
||||
const pushEvent = (event: SdkEvent) => {
|
||||
eventQueue.push(event);
|
||||
if (resolveWaiting) {
|
||||
resolveWaiting();
|
||||
resolveWaiting = null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForEvent = (): Promise<void> => {
|
||||
if (eventQueue.length > 0 || sessionComplete) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveWaiting = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||
|
||||
// Create session with streaming enabled for real-time events
|
||||
const session = await client.createSession({
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
// AutoMaker is designed for fully autonomous AI agent operation.
|
||||
// Security boundary is provided by Docker containerization (see CLAUDE.md).
|
||||
// User is warned about this at app startup.
|
||||
onPermissionRequest: async (
|
||||
request: PermissionRequest
|
||||
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else if (event.type === 'session.error') {
|
||||
const errorEvent = event as SdkSessionErrorEvent;
|
||||
sessionError = new Error(errorEvent.data.message);
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
await waitForEvent();
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
|
||||
// Process all queued events
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!;
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
// Add session_id if not present
|
||||
if (!normalized.session_id) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
// Log but don't throw cleanup errors - the original error is more important
|
||||
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
|
||||
}
|
||||
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map errors to CopilotError
|
||||
if (error instanceof Error) {
|
||||
logger.error(`Copilot SDK error: ${error.message}`);
|
||||
const errorInfo = this.mapError(error.message, null);
|
||||
throw this.createError(
|
||||
errorInfo.code as CopilotErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Copilot-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a CopilotError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CopilotErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CopilotError {
|
||||
const error = new Error(message) as CopilotError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CopilotError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Copilot CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses GitHub CLI (gh) to check Copilot authentication status.
|
||||
* The Copilot CLI relies on gh auth for authentication.
|
||||
*/
|
||||
async checkAuth(): Promise<CopilotAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Try to check GitHub CLI authentication status first
|
||||
// The Copilot CLI uses gh auth for authentication
|
||||
try {
|
||||
const ghStatus = execSync('gh auth status --hostname github.com', {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
|
||||
|
||||
// Parse gh auth status output
|
||||
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
|
||||
if (loggedInMatch) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
login: loggedInMatch[1],
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for token auth
|
||||
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch (ghError) {
|
||||
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
|
||||
}
|
||||
|
||||
// Try Copilot-specific auth check if gh is not available
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" auth status`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
|
||||
|
||||
if (result.includes('authenticated') || result.includes('logged in')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'cli',
|
||||
};
|
||||
}
|
||||
} catch (copilotError) {
|
||||
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
|
||||
}
|
||||
|
||||
// Check for GITHUB_TOKEN environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
statusMessage: 'Using GITHUB_TOKEN environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for gh config file
|
||||
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
try {
|
||||
await fs.access(ghConfigPath);
|
||||
const content = await fs.readFile(ghConfigPath, 'utf8');
|
||||
if (content.includes('github.com') && content.includes('oauth_token')) {
|
||||
logger.debug('checkAuth: Found gh config with oauth_token');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No gh config found');
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
error:
|
||||
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from the CLI at runtime
|
||||
*/
|
||||
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to list models using the CLI
|
||||
const result = execSync(`"${this.cliPath}" models list --format json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const models = JSON.parse(result) as CopilotRuntimeModel[];
|
||||
this.runtimeModels = models;
|
||||
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
// Clear cache on failure to avoid returning stale data
|
||||
this.runtimeModels = null;
|
||||
logger.debug(`Failed to fetch runtime models: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Copilot models
|
||||
*
|
||||
* Returns both static model definitions and runtime-discovered models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
// Start with static model definitions - explicitly typed to allow runtime models
|
||||
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // Full model ID with copilot- prefix
|
||||
name: config.label,
|
||||
modelString: id.replace('copilot-', ''), // Bare model for CLI
|
||||
provider: 'copilot',
|
||||
description: config.description,
|
||||
supportsTools: config.supportsTools,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
);
|
||||
|
||||
// Add runtime models if available (discovered via CLI)
|
||||
if (this.runtimeModels) {
|
||||
for (const runtimeModel of this.runtimeModels) {
|
||||
// Skip if already in static list
|
||||
const staticId = `copilot-${runtimeModel.id}`;
|
||||
if (staticModels.some((m) => m.id === staticId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
staticModels.push({
|
||||
id: staticId,
|
||||
name: runtimeModel.name || runtimeModel.id,
|
||||
modelString: runtimeModel.id,
|
||||
provider: 'copilot',
|
||||
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
|
||||
supportsTools: true,
|
||||
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
|
||||
contextWindow: runtimeModel.capabilities?.maxInputTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return staticModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*
|
||||
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
|
||||
* This may change in future versions of the Copilot SDK.
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime models have been cached
|
||||
*/
|
||||
hasCachedModels(): boolean {
|
||||
return this.runtimeModels !== null && this.runtimeModels.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the runtime model cache
|
||||
*/
|
||||
clearModelCache(): void {
|
||||
this.runtimeModels = null;
|
||||
logger.debug('Cleared Copilot model cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from CLI and return all available models
|
||||
*/
|
||||
async refreshModels(): Promise<ModelDefinition[]> {
|
||||
logger.debug('Refreshing Copilot models from CLI');
|
||||
await this.fetchRuntimeModels();
|
||||
return this.getAvailableModels();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { validateBareModelId } from '@automaker/types';
|
||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
@@ -150,6 +151,8 @@ function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Uses shared normalizeTodos utility for consistent todo normalization.
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
@@ -160,17 +163,9 @@ function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
|
||||
// Normalize write_todos using shared utility
|
||||
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
|
||||
return {
|
||||
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
|
||||
content: todo.description || '',
|
||||
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
|
||||
status: todo.status === 'cancelled' ? 'completed' : todo.status,
|
||||
// Use description as activeForm since Gemini doesn't have it
|
||||
activeForm: todo.description || '',
|
||||
})),
|
||||
};
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Gemini provider
|
||||
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
|
||||
|
||||
// Copilot provider (GitHub Copilot SDK)
|
||||
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
@@ -23,6 +24,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
copilot: '.copilot-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -275,6 +277,7 @@ import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
import { CopilotProvider } from './copilot-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -317,3 +320,11 @@ registerProvider('gemini', {
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
// Register Copilot provider (GitHub Copilot SDK)
|
||||
registerProvider('copilot', {
|
||||
factory: () => new CopilotProvider(),
|
||||
aliases: ['github-copilot', 'github'],
|
||||
canHandleModel: (model: string) => isCopilotModel(model),
|
||||
priority: 6, // High priority - check before Codex since both can handle GPT models
|
||||
});
|
||||
|
||||
112
apps/server/src/providers/tool-normalization.ts
Normal file
112
apps/server/src/providers/tool-normalization.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared tool normalization utilities for AI providers
|
||||
*
|
||||
* These utilities help normalize tool inputs from various AI providers
|
||||
* to the standard format expected by the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Valid todo status values in the standard format
|
||||
*/
|
||||
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* Set of valid status values for validation
|
||||
*/
|
||||
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
|
||||
|
||||
/**
|
||||
* Todo item from various AI providers (Gemini, Copilot, etc.)
|
||||
*/
|
||||
interface ProviderTodo {
|
||||
description?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard todo format used by the application
|
||||
*/
|
||||
interface NormalizedTodo {
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a provider status value to a valid TodoStatus
|
||||
*/
|
||||
function normalizeStatus(status: string | undefined): TodoStatus {
|
||||
if (!status) return 'pending';
|
||||
if (status === 'cancelled' || status === 'canceled') return 'completed';
|
||||
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize todos array from provider format to standard format
|
||||
*
|
||||
* Handles different formats from providers:
|
||||
* - Gemini: { description, status } with 'cancelled' as possible status
|
||||
* - Copilot: { content/description, status } with 'cancelled' as possible status
|
||||
*
|
||||
* Output format (Claude/Standard):
|
||||
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
|
||||
*/
|
||||
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
|
||||
if (!todos) return [];
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content || todo.description || '',
|
||||
status: normalizeStatus(todo.status),
|
||||
// Use content/description as activeForm since providers may not have it
|
||||
activeForm: todo.content || todo.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize file path parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for file paths:
|
||||
* - path, file, filename, filePath -> file_path
|
||||
*/
|
||||
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.file_path) {
|
||||
if (input.path) normalized.file_path = input.path;
|
||||
else if (input.file) normalized.file_path = input.file;
|
||||
else if (input.filename) normalized.file_path = input.filename;
|
||||
else if (input.filePath) normalized.file_path = input.filePath;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shell command parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for commands:
|
||||
* - cmd, script -> command
|
||||
*/
|
||||
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.command) {
|
||||
if (input.cmd) normalized.command = input.cmd;
|
||||
else if (input.script) normalized.command = input.script;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize search pattern parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for search patterns:
|
||||
* - query, search, regex -> pattern
|
||||
*/
|
||||
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.pattern) {
|
||||
if (input.query) normalized.pattern = input.query;
|
||||
else if (input.search) normalized.pattern = input.search;
|
||||
else if (input.regex) normalized.pattern = input.regex;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -25,6 +26,64 @@ const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
/**
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
*/
|
||||
interface FeaturesExtractionResult {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for features output format (Claude/Codex structured output)
|
||||
*/
|
||||
const featuresOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
|
||||
category: { type: 'string', description: 'Feature category' },
|
||||
title: { type: 'string', description: 'Short, descriptive title' },
|
||||
description: { type: 'string', description: 'Detailed feature description' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 1 (highest) to 5 (lowest)',
|
||||
},
|
||||
complexity: {
|
||||
type: 'string',
|
||||
enum: ['simple', 'moderate', 'complex'],
|
||||
description: 'Implementation complexity',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'IDs of features this depends on',
|
||||
},
|
||||
},
|
||||
required: ['id', 'title', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['features'],
|
||||
} as const;
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
@@ -136,23 +195,80 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Codex models need extended timeout for generating many features.
|
||||
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
|
||||
// The Codex provider has a special 5-minute base timeout for feature generation.
|
||||
const isCodex = isCodexModel(model);
|
||||
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
|
||||
|
||||
if (isCodex) {
|
||||
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
|
||||
}
|
||||
if (effectiveReasoningEffort) {
|
||||
logger.info('Reasoning effort:', effectiveReasoningEffort);
|
||||
}
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must have this exact structure:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "unique-feature-id",
|
||||
"category": "Category Name",
|
||||
"title": "Short Feature Title",
|
||||
"description": "Detailed description of the feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple|moderate|complex",
|
||||
"dependencies": ["other-feature-id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
|
||||
5. Priority ranges from 1 (highest) to 5 (lowest)
|
||||
6. Complexity must be one of: "simple", "moderate", "complex"
|
||||
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: featuresOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -163,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = result.text;
|
||||
// Get response content - prefer structured output if available
|
||||
let contentForParsing: string;
|
||||
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
if (result.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
logger.info('✅ Received structured output from model');
|
||||
contentForParsing = JSON.stringify(result.structured_output);
|
||||
logger.debug('Structured output:', contentForParsing);
|
||||
} else {
|
||||
// Use text response (for non-Claude/Codex models or fallback)
|
||||
// Pre-extract JSON to handle conversational text that may surround the JSON response
|
||||
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
|
||||
const rawText = result.text;
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(rawText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
// Pre-extract JSON from response - handles conversational text around the JSON
|
||||
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
|
||||
logger,
|
||||
});
|
||||
if (extracted) {
|
||||
contentForParsing = JSON.stringify(extracted);
|
||||
logger.info('✅ Pre-extracted JSON from text response');
|
||||
} else {
|
||||
// If pre-extraction fails, we know the next step will also fail.
|
||||
// Throw an error here to avoid redundant parsing and make the failure point clearer.
|
||||
logger.error(
|
||||
'❌ Could not extract features JSON from model response. Full response text was:\n' +
|
||||
rawText
|
||||
);
|
||||
const errorMessage =
|
||||
'Failed to parse features from model response: No valid JSON with a "features" array found.';
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: errorMessage,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
@@ -120,10 +120,13 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
@@ -34,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Type for extracted tech stack JSON response
|
||||
*/
|
||||
interface TechStackExtractionResult {
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
||||
*/
|
||||
const techStackOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
technologies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of technologies detected in the project',
|
||||
},
|
||||
},
|
||||
required: ['technologies'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
@@ -176,8 +199,14 @@ export async function syncSpec(
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
@@ -193,6 +222,16 @@ Return ONLY this JSON format, no other text:
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
// Add explicit JSON instructions for non-Claude/Codex models
|
||||
if (!useStructuredOutput) {
|
||||
techAnalysisPrompt = `${techAnalysisPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Your entire response should be valid JSON starting with { and ending with }.
|
||||
3. No explanations, no markdown, no text before or after the JSON.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
@@ -206,44 +245,67 @@ Return ONLY this JSON format, no other text:
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: techStackOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response
|
||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(parsed.technologies)) {
|
||||
const newTechStack = parsed.technologies as string[];
|
||||
// Parse tech stack from response - prefer structured output if available
|
||||
let parsedTechnologies: string[] | null = null;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
if (techResult.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
||||
if (Array.isArray(structured.technologies)) {
|
||||
parsedTechnologies = structured.technologies;
|
||||
logger.info('✅ Received structured output for tech analysis');
|
||||
}
|
||||
} else {
|
||||
// Fall back to text parsing for non-Claude/Codex models
|
||||
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
||||
logger,
|
||||
requiredKey: 'technologies',
|
||||
requireArray: true,
|
||||
});
|
||||
if (extracted && Array.isArray(extracted.technologies)) {
|
||||
parsedTechnologies = extracted.technologies;
|
||||
logger.info('✅ Extracted tech stack from text response');
|
||||
} else {
|
||||
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
if (parsedTechnologies) {
|
||||
const newTechStack = parsedTechnologies;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (
|
||||
result.techStackUpdates.added.length > 0 ||
|
||||
result.techStackUpdates.removed.length > 0
|
||||
) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
@@ -22,11 +23,16 @@ import { createImportHandler, createConflictCheckHandler } from './routes/import
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter
|
||||
events?: EventEmitter,
|
||||
autoModeService?: AutoModeService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
|
||||
router.post(
|
||||
'/list',
|
||||
validatePathParams('projectPath'),
|
||||
createListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post(
|
||||
'/create',
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
featureName: created.title || 'Untitled Feature',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* POST /list endpoint - List all features for a project
|
||||
*
|
||||
* Also performs orphan detection when a project is loaded to identify
|
||||
* features whose branches no longer exist. This runs on every project load/switch.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader) {
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
@@ -17,6 +24,26 @@ export function createListHandler(featureLoader: FeatureLoader) {
|
||||
}
|
||||
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Run orphan detection in background when project is loaded
|
||||
// This detects features whose branches no longer exist (e.g., after merge/delete)
|
||||
// We don't await this to keep the list response fast
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, features });
|
||||
} catch (error) {
|
||||
logError(error, 'List features failed');
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
|
||||
await secureFs.mkdir(boardDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Use a fixed filename for the board background (overwrite previous)
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
|
||||
await secureFs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isCodexModel,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -124,8 +125,9 @@ async function runValidation(
|
||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||
|
||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||
let finalPrompt = basePrompt;
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
12
apps/server/src/routes/projects/common.ts
Normal file
12
apps/server/src/routes/projects/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for projects routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Projects');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
27
apps/server/src/routes/projects/index.ts
Normal file
27
apps/server/src/routes/projects/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Projects routes - HTTP API for multi-project overview and management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { createOverviewHandler } from './routes/overview.js';
|
||||
|
||||
export function createProjectsRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /overview - Get aggregate status for all projects
|
||||
router.get(
|
||||
'/overview',
|
||||
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* GET /overview endpoint - Get aggregate status for all projects
|
||||
*
|
||||
* Returns a complete overview of all projects including:
|
||||
* - Individual project status (features, auto-mode state)
|
||||
* - Aggregate metrics across all projects
|
||||
* - Recent activity feed (placeholder for future implementation)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
ProjectStatus,
|
||||
AggregateStatus,
|
||||
MultiProjectOverview,
|
||||
FeatureStatusCounts,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
ProjectHealthStatus,
|
||||
Feature,
|
||||
ProjectRef,
|
||||
} from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Compute feature status counts from a list of features
|
||||
*/
|
||||
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
|
||||
const counts: FeatureStatusCounts = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
switch (feature.status) {
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'running':
|
||||
case 'generating_spec':
|
||||
case 'in_progress':
|
||||
counts.running++;
|
||||
break;
|
||||
case 'waiting_approval':
|
||||
// waiting_approval means agent finished, needs human review - count as pending
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'completed':
|
||||
counts.completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
counts.failed++;
|
||||
break;
|
||||
case 'verified':
|
||||
counts.verified++;
|
||||
break;
|
||||
default:
|
||||
// Unknown status, treat as pending
|
||||
counts.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the overall health status of a project based on its feature statuses
|
||||
*/
|
||||
function computeHealthStatus(
|
||||
featureCounts: FeatureStatusCounts,
|
||||
isAutoModeRunning: boolean
|
||||
): ProjectHealthStatus {
|
||||
const totalFeatures =
|
||||
featureCounts.pending +
|
||||
featureCounts.running +
|
||||
featureCounts.completed +
|
||||
featureCounts.failed +
|
||||
featureCounts.verified;
|
||||
|
||||
// If there are failed features, the project has errors
|
||||
if (featureCounts.failed > 0) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// If there are running features or auto mode is running with pending work
|
||||
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Pending work but no active execution
|
||||
if (featureCounts.pending > 0) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
// If all features are completed or verified
|
||||
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Default to idle
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent activity timestamp from features
|
||||
*/
|
||||
function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
if (features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let latestTimestamp: number = 0;
|
||||
|
||||
for (const feature of features) {
|
||||
// Check startedAt timestamp (the main timestamp available on Feature)
|
||||
if (feature.startedAt) {
|
||||
const timestamp = new Date(feature.startedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check planSpec timestamps if available
|
||||
if (feature.planSpec?.generatedAt) {
|
||||
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
if (feature.planSpec?.approvedAt) {
|
||||
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get all projects from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
try {
|
||||
// Load features for this project
|
||||
const features = await featureLoader.getAll(projectRef.path);
|
||||
const featureCounts = computeFeatureCounts(features);
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
// This ensures we only count features that are actually running in memory
|
||||
const liveRunningCount = allRunningAgents.filter(
|
||||
(agent) => agent.projectPath === projectRef.path
|
||||
).length;
|
||||
featureCounts.running = liveRunningCount;
|
||||
|
||||
// Get notification count for this project
|
||||
let unreadNotificationCount = 0;
|
||||
try {
|
||||
const notifications = await notificationService.getNotifications(projectRef.path);
|
||||
unreadNotificationCount = notifications.filter((n) => !n.read).length;
|
||||
} catch {
|
||||
// Ignore notification errors - project may not have any notifications yet
|
||||
}
|
||||
|
||||
// Compute health status
|
||||
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
|
||||
|
||||
// Get last activity timestamp
|
||||
const lastActivityAt = getLastActivityAt(features);
|
||||
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus,
|
||||
featureCounts,
|
||||
totalFeatures,
|
||||
lastActivityAt,
|
||||
isAutoModeRunning,
|
||||
activeBranch: autoModeStatus.branchName ?? undefined,
|
||||
unreadNotificationCount,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, `Failed to load project status: ${projectRef.name}`);
|
||||
// Return a minimal status for projects that fail to load
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus: 'error' as ProjectHealthStatus,
|
||||
featureCounts: {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const projectStatuses = await Promise.all(projectStatusPromises);
|
||||
|
||||
// Compute aggregate metrics
|
||||
const aggregateFeatureCounts: AggregateFeatureCounts = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
const aggregateProjectCounts: AggregateProjectCounts = {
|
||||
total: projectStatuses.length,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
};
|
||||
|
||||
let totalUnreadNotifications = 0;
|
||||
let projectsWithAutoModeRunning = 0;
|
||||
|
||||
for (const status of projectStatuses) {
|
||||
// Aggregate feature counts
|
||||
aggregateFeatureCounts.total += status.totalFeatures;
|
||||
aggregateFeatureCounts.pending += status.featureCounts.pending;
|
||||
aggregateFeatureCounts.running += status.featureCounts.running;
|
||||
aggregateFeatureCounts.completed += status.featureCounts.completed;
|
||||
aggregateFeatureCounts.failed += status.featureCounts.failed;
|
||||
aggregateFeatureCounts.verified += status.featureCounts.verified;
|
||||
|
||||
// Aggregate project counts by health status
|
||||
switch (status.healthStatus) {
|
||||
case 'active':
|
||||
aggregateProjectCounts.active++;
|
||||
break;
|
||||
case 'idle':
|
||||
aggregateProjectCounts.idle++;
|
||||
break;
|
||||
case 'waiting':
|
||||
aggregateProjectCounts.waiting++;
|
||||
break;
|
||||
case 'error':
|
||||
aggregateProjectCounts.withErrors++;
|
||||
break;
|
||||
case 'completed':
|
||||
aggregateProjectCounts.allCompleted++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate notifications
|
||||
totalUnreadNotifications += status.unreadNotificationCount;
|
||||
|
||||
// Count projects with auto-mode running
|
||||
if (status.isAutoModeRunning) {
|
||||
projectsWithAutoModeRunning++;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateStatus: AggregateStatus = {
|
||||
projectCounts: aggregateProjectCounts,
|
||||
featureCounts: aggregateFeatureCounts,
|
||||
totalUnreadNotifications,
|
||||
projectsWithAutoModeRunning,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build the response (recentActivity is empty for now - can be populated later)
|
||||
const overview: MultiProjectOverview = {
|
||||
projects: projectStatuses,
|
||||
aggregate: aggregateStatus,
|
||||
recentActivity: [], // Placeholder for future activity feed implementation
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...overview,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get project overview failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Provider Usage Routes
|
||||
*
|
||||
* API endpoints for fetching usage data from all AI providers.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/provider-usage - Get usage for all enabled providers
|
||||
* - GET /api/provider-usage/:providerId - Get usage for a specific provider
|
||||
* - GET /api/provider-usage/availability - Check availability of all providers
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { UsageProviderId } from '@automaker/types';
|
||||
import { ProviderUsageTracker } from '../../services/provider-usage-tracker.js';
|
||||
|
||||
const logger = createLogger('ProviderUsageRoutes');
|
||||
|
||||
// Valid provider IDs
|
||||
const VALID_PROVIDER_IDS: UsageProviderId[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
];
|
||||
|
||||
export function createProviderUsageRoutes(tracker: ProviderUsageTracker): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage
|
||||
* Fetch usage for all enabled providers
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const usage = await tracker.fetchAllUsage(forceRefresh);
|
||||
res.json({
|
||||
success: true,
|
||||
data: usage,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error fetching all provider usage:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage/availability
|
||||
* Check which providers are available
|
||||
*/
|
||||
router.get('/availability', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const availability = await tracker.checkAvailability();
|
||||
res.json({
|
||||
success: true,
|
||||
data: availability,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error checking provider availability:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/provider-usage/:providerId
|
||||
* Fetch usage for a specific provider
|
||||
*/
|
||||
router.get('/:providerId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const providerId = req.params.providerId as UsageProviderId;
|
||||
|
||||
// Validate provider ID
|
||||
if (!VALID_PROVIDER_IDS.includes(providerId)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid provider ID: ${providerId}. Valid providers: ${VALID_PROVIDER_IDS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if provider is enabled
|
||||
if (!tracker.isProviderEnabled(providerId)) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Provider is disabled',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const usage = await tracker.fetchProviderUsage(providerId, forceRefresh);
|
||||
|
||||
if (!usage) {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Failed to fetch usage data',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: usage,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`Error fetching usage for ${req.params.providerId}:`, error);
|
||||
|
||||
// Return 200 with error in data to avoid triggering logout
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Marker file used to indicate a provider has been explicitly disconnected by user
|
||||
*/
|
||||
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
@@ -27,6 +27,14 @@ import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||
import { createGeminiStatusHandler } from './routes/gemini-status.js';
|
||||
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
|
||||
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
|
||||
import { createCopilotStatusHandler } from './routes/copilot-status.js';
|
||||
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
|
||||
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
|
||||
import {
|
||||
createGetCopilotModelsHandler,
|
||||
createRefreshCopilotModelsHandler,
|
||||
createClearCopilotCacheHandler,
|
||||
} from './routes/copilot-models.js';
|
||||
import {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -80,6 +88,16 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
router.post('/deauth-gemini', createDeauthGeminiHandler());
|
||||
|
||||
// Copilot CLI routes
|
||||
router.get('/copilot-status', createCopilotStatusHandler());
|
||||
router.post('/auth-copilot', createAuthCopilotHandler());
|
||||
router.post('/deauth-copilot', createDeauthCopilotHandler());
|
||||
|
||||
// Copilot Dynamic Model Discovery routes
|
||||
router.get('/copilot/models', createGetCopilotModelsHandler());
|
||||
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
|
||||
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { connectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-copilot
|
||||
* Removes the disconnection marker to allow Copilot CLI to be used
|
||||
*/
|
||||
export function createAuthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await connectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copilot Dynamic Models API Routes
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
|
||||
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import type { ModelDefinition } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CopilotModelsRoute');
|
||||
|
||||
// Singleton provider instance for caching
|
||||
let providerInstance: CopilotProvider | null = null;
|
||||
|
||||
function getProvider(): CopilotProvider {
|
||||
if (!providerInstance) {
|
||||
providerInstance = new CopilotProvider();
|
||||
}
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for models endpoint
|
||||
*/
|
||||
interface ModelsResponse {
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
count?: number;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot/models
|
||||
*
|
||||
* Returns currently available models (from cache if available).
|
||||
* Query params:
|
||||
* - refresh=true: Force refresh from CLI before returning
|
||||
*
|
||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
||||
*/
|
||||
export function createGetCopilotModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
let models: ModelDefinition[];
|
||||
let cached = true;
|
||||
|
||||
if (forceRefresh) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
// Check if we have cached models
|
||||
if (!provider.hasCachedModels()) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
models = provider.getAvailableModels();
|
||||
}
|
||||
}
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/models/refresh
|
||||
*
|
||||
* Forces a refresh of models from the Copilot CLI.
|
||||
*/
|
||||
export function createRefreshCopilotModelsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const models = await provider.refreshModels();
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/cache/clear
|
||||
*
|
||||
* Clears the model cache, forcing a fresh fetch on next access.
|
||||
*/
|
||||
export function createClearCopilotCacheHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
provider.clearModelCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot model cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear Copilot cache failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
async function isCopilotDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot-status
|
||||
* Returns Copilot CLI installation and authentication status
|
||||
*/
|
||||
export function createCopilotStatusHandler() {
|
||||
const installCommand = 'npm install -g @github/copilot';
|
||||
const loginCommand = 'gh auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isCopilotDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CopilotProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
login: auth.login,
|
||||
host: auth.host,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-copilot
|
||||
* Creates a marker file to disconnect Copilot CLI from the app
|
||||
*/
|
||||
export function createDeauthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await disconnectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubPRCacheEntry {
|
||||
prs: Map<string, WorktreePRInfo>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
* project doesn't have a GitHub remote configured. Results are cached
|
||||
* briefly to avoid hammering GitHub on frequent worktree polls.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
async function fetchGitHubPRs(
|
||||
projectPath: string,
|
||||
forceRefresh = false
|
||||
): Promise<Map<string, WorktreePRInfo>> {
|
||||
const now = Date.now();
|
||||
const cached = githubPRCache.get(projectPath);
|
||||
|
||||
// Return cached result if valid and not forcing refresh
|
||||
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
|
||||
return cached.prs;
|
||||
}
|
||||
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Only update cache on successful fetch
|
||||
githubPRCache.set(projectPath, {
|
||||
prs: prMap,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
// On fetch failure, return stale cached data if available to avoid
|
||||
// repeated API calls during GitHub API flakiness or temporary outages
|
||||
if (cached) {
|
||||
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
|
||||
// Extend cache TTL to avoid repeated retries during outages
|
||||
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
|
||||
return cached.prs;
|
||||
}
|
||||
// No cache available, log warning and return empty map
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ export function createListHandler() {
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
80
apps/server/src/services/copilot-connection-service.ts
Normal file
80
apps/server/src/services/copilot-connection-service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copilot Connection Service
|
||||
*
|
||||
* Handles the connection and disconnection of Copilot CLI to the app.
|
||||
* Uses a marker file to track the disconnected state.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
|
||||
|
||||
const logger = createLogger('CopilotConnectionService');
|
||||
|
||||
/**
|
||||
* Get the path to the disconnected marker file
|
||||
*/
|
||||
function getMarkerPath(projectRoot?: string): string {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect Copilot CLI to the app by removing the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the connection is established
|
||||
*/
|
||||
export async function connectCopilot(projectRoot?: string): Promise<void> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
logger.info('Copilot CLI connected to app (marker removed)');
|
||||
} catch (error) {
|
||||
// File doesn't exist - that's fine, Copilot is already connected
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to remove disconnected marker:', error);
|
||||
throw error;
|
||||
}
|
||||
logger.debug('Copilot already connected (no marker file found)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Copilot CLI from the app by creating the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the disconnection is complete
|
||||
*/
|
||||
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
|
||||
logger.info('Copilot CLI disconnected from app (marker created)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Copilot CLI is connected (not disconnected)
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves to true if connected, false if disconnected
|
||||
*/
|
||||
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.access(markerPath);
|
||||
return false; // Marker exists = disconnected
|
||||
} catch {
|
||||
return true; // Marker doesn't exist = connected
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* GitHub Copilot Usage Service
|
||||
*
|
||||
* Fetches usage data from GitHub's Copilot API using GitHub OAuth.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. GitHub CLI token (~/.config/gh/hosts.yml)
|
||||
* 2. GitHub OAuth device flow (stored in config)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://api.github.com/copilot_internal/user - Quota and plan info
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CopilotProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CopilotUsage');
|
||||
|
||||
// GitHub API endpoint for Copilot
|
||||
const COPILOT_USER_ENDPOINT = 'https://api.github.com/copilot_internal/user';
|
||||
|
||||
interface CopilotQuotaSnapshot {
|
||||
percentageUsed?: number;
|
||||
percentageRemaining?: number;
|
||||
limit?: number;
|
||||
used?: number;
|
||||
}
|
||||
|
||||
interface CopilotUserResponse {
|
||||
copilotPlan?: string;
|
||||
copilot_plan?: string;
|
||||
quotaSnapshots?: {
|
||||
premiumInteractions?: CopilotQuotaSnapshot;
|
||||
chat?: CopilotQuotaSnapshot;
|
||||
};
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export class CopilotUsageService {
|
||||
private cachedToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if GitHub Copilot credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const token = await this.getGitHubToken();
|
||||
return !!token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub token from various sources
|
||||
*/
|
||||
private async getGitHubToken(): Promise<string | null> {
|
||||
if (this.cachedToken) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
this.cachedToken = process.env.GITHUB_TOKEN;
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 2. Check GH_TOKEN (GitHub CLI uses this)
|
||||
if (process.env.GH_TOKEN) {
|
||||
this.cachedToken = process.env.GH_TOKEN;
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// 3. Try to get token from GitHub CLI
|
||||
try {
|
||||
const token = execSync('gh auth token', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
if (token) {
|
||||
this.cachedToken = token;
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch {
|
||||
logger.debug('Failed to get token from gh CLI');
|
||||
}
|
||||
|
||||
// 4. Check GitHub CLI hosts.yml file
|
||||
const ghHostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
if (fs.existsSync(ghHostsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(ghHostsPath, 'utf8');
|
||||
// Simple YAML parsing for oauth_token
|
||||
const match = content.match(/oauth_token:\s*(.+)/);
|
||||
if (match) {
|
||||
this.cachedToken = match[1].trim();
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read gh hosts.yml:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check CodexBar config (for users who also use CodexBar)
|
||||
const codexbarConfigPath = path.join(os.homedir(), '.codexbar', 'config.json');
|
||||
if (fs.existsSync(codexbarConfigPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(codexbarConfigPath, 'utf8');
|
||||
const config = JSON.parse(content);
|
||||
if (config.github?.oauth_token) {
|
||||
this.cachedToken = config.github.oauth_token;
|
||||
return this.cachedToken;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read CodexBar config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to GitHub Copilot API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const token = await this.getGitHubToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'automaker/1.0',
|
||||
// Copilot-specific headers (from CodexBar reference)
|
||||
'Editor-Version': 'vscode/1.96.2',
|
||||
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
|
||||
'X-Github-Api-Version': '2025-04-01',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached token on auth failure
|
||||
this.cachedToken = null;
|
||||
logger.warn('GitHub Copilot API authentication failed');
|
||||
return null;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// User may not have Copilot access
|
||||
logger.info('GitHub Copilot not available for this user');
|
||||
return null;
|
||||
}
|
||||
logger.error(`GitHub Copilot API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from GitHub Copilot API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from GitHub Copilot
|
||||
*/
|
||||
async fetchUsageData(): Promise<CopilotProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting GitHub Copilot usage fetch...');
|
||||
|
||||
const baseUsage: CopilotProviderUsage = {
|
||||
providerId: 'copilot',
|
||||
providerName: 'GitHub Copilot',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if token is available
|
||||
const hasToken = await this.getGitHubToken();
|
||||
if (!hasToken) {
|
||||
baseUsage.error = 'GitHub authentication not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch Copilot user data
|
||||
const userResponse = await this.makeRequest<CopilotUserResponse>(COPILOT_USER_ENDPOINT);
|
||||
if (!userResponse) {
|
||||
baseUsage.error = 'Failed to fetch GitHub Copilot usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse quota snapshots
|
||||
const quotas = userResponse.quotaSnapshots;
|
||||
if (quotas) {
|
||||
// Premium interactions quota
|
||||
if (quotas.premiumInteractions) {
|
||||
const premium = quotas.premiumInteractions;
|
||||
const usedPercent =
|
||||
premium.percentageUsed !== undefined
|
||||
? premium.percentageUsed
|
||||
: premium.percentageRemaining !== undefined
|
||||
? 100 - premium.percentageRemaining
|
||||
: 0;
|
||||
|
||||
const premiumWindow: UsageWindow = {
|
||||
name: 'Premium Interactions',
|
||||
usedPercent,
|
||||
resetsAt: '', // GitHub doesn't provide reset time
|
||||
resetText: 'Resets monthly',
|
||||
limit: premium.limit,
|
||||
used: premium.used,
|
||||
};
|
||||
|
||||
baseUsage.primary = premiumWindow;
|
||||
baseUsage.premiumInteractions = premiumWindow;
|
||||
}
|
||||
|
||||
// Chat quota
|
||||
if (quotas.chat) {
|
||||
const chat = quotas.chat;
|
||||
const usedPercent =
|
||||
chat.percentageUsed !== undefined
|
||||
? chat.percentageUsed
|
||||
: chat.percentageRemaining !== undefined
|
||||
? 100 - chat.percentageRemaining
|
||||
: 0;
|
||||
|
||||
const chatWindow: UsageWindow = {
|
||||
name: 'Chat',
|
||||
usedPercent,
|
||||
resetsAt: '',
|
||||
resetText: 'Resets monthly',
|
||||
limit: chat.limit,
|
||||
used: chat.used,
|
||||
};
|
||||
|
||||
baseUsage.secondary = chatWindow;
|
||||
baseUsage.chatQuota = chatWindow;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse plan type
|
||||
const planType = userResponse.copilotPlan || userResponse.copilot_plan || userResponse.plan;
|
||||
if (planType) {
|
||||
baseUsage.copilotPlan = planType;
|
||||
baseUsage.plan = {
|
||||
type: planType,
|
||||
displayName: this.formatPlanName(planType),
|
||||
isPaid: planType.toLowerCase() !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ GitHub Copilot usage: Premium=${baseUsage.premiumInteractions?.usedPercent || 0}%, ` +
|
||||
`Chat=${baseUsage.chatQuota?.usedPercent || 0}%, Plan=${planType || 'unknown'}`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format plan name for display
|
||||
*/
|
||||
private formatPlanName(plan: string): string {
|
||||
const planMap: Record<string, string> = {
|
||||
free: 'Free',
|
||||
individual: 'Individual',
|
||||
business: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
return planMap[plan.toLowerCase()] || plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached token
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedToken = null;
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* Cursor Usage Service
|
||||
*
|
||||
* Fetches usage data from Cursor's API using session cookies or access token.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods (in priority order):
|
||||
* 1. Cached session cookie from browser import
|
||||
* 2. Access token from credentials file
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://cursor.com/api/usage-summary - Plan usage, on-demand, billing dates
|
||||
* - GET https://cursor.com/api/auth/me - User email and name
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { CursorProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('CursorUsage');
|
||||
|
||||
// Cursor API endpoints
|
||||
const CURSOR_API_BASE = 'https://cursor.com/api';
|
||||
const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/usage-summary`;
|
||||
const AUTH_ME_ENDPOINT = `${CURSOR_API_BASE}/auth/me`;
|
||||
|
||||
// Session cookie names used by Cursor
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
'WorkosCursorSessionToken',
|
||||
'__Secure-next-auth.session-token',
|
||||
'next-auth.session-token',
|
||||
];
|
||||
|
||||
interface CursorUsageSummary {
|
||||
planUsage?: {
|
||||
percent: number;
|
||||
resetAt?: string;
|
||||
};
|
||||
onDemandUsage?: {
|
||||
percent: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
billingCycleEnd?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
interface CursorAuthMe {
|
||||
email?: string;
|
||||
name?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export class CursorUsageService {
|
||||
private cachedSessionCookie: string | null = null;
|
||||
private cachedAccessToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Cursor credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return await this.hasValidCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have valid Cursor credentials
|
||||
*/
|
||||
private async hasValidCredentials(): Promise<boolean> {
|
||||
const token = await this.getAccessToken();
|
||||
return !!token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from credentials file
|
||||
*/
|
||||
private async getAccessToken(): Promise<string | null> {
|
||||
if (this.cachedAccessToken) {
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
|
||||
// Check environment variable first
|
||||
if (process.env.CURSOR_ACCESS_TOKEN) {
|
||||
this.cachedAccessToken = process.env.CURSOR_ACCESS_TOKEN;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
|
||||
// Check credentials files
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken) {
|
||||
this.cachedAccessToken = creds.accessToken;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
if (creds.token) {
|
||||
this.cachedAccessToken = creds.token;
|
||||
return this.cachedAccessToken;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read credentials from ${credPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session cookie for API calls
|
||||
* Returns a cookie string like "WorkosCursorSessionToken=xxx"
|
||||
*/
|
||||
private async getSessionCookie(): Promise<string | null> {
|
||||
if (this.cachedSessionCookie) {
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
|
||||
// Check for cookie in environment
|
||||
if (process.env.CURSOR_SESSION_COOKIE) {
|
||||
this.cachedSessionCookie = process.env.CURSOR_SESSION_COOKIE;
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
|
||||
// Check for saved session file
|
||||
const sessionPath = path.join(os.homedir(), '.cursor', 'session.json');
|
||||
try {
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
const content = fs.readFileSync(sessionPath, 'utf8');
|
||||
const session = JSON.parse(content);
|
||||
for (const cookieName of SESSION_COOKIE_NAMES) {
|
||||
if (session[cookieName]) {
|
||||
this.cachedSessionCookie = `${cookieName}=${session[cookieName]}`;
|
||||
return this.cachedSessionCookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read session file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Cursor API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
};
|
||||
|
||||
// Try access token first
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// Try session cookie as fallback
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
if (sessionCookie) {
|
||||
headers['Cookie'] = sessionCookie;
|
||||
}
|
||||
|
||||
if (!accessToken && !sessionCookie) {
|
||||
logger.warn('No Cursor credentials available for API request');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached credentials on auth failure
|
||||
this.cachedAccessToken = null;
|
||||
this.cachedSessionCookie = null;
|
||||
logger.warn('Cursor API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`Cursor API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Cursor API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Cursor
|
||||
*/
|
||||
async fetchUsageData(): Promise<CursorProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting Cursor usage fetch...');
|
||||
|
||||
const baseUsage: CursorProviderUsage = {
|
||||
providerId: 'cursor',
|
||||
providerName: 'Cursor',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if credentials are available
|
||||
const hasCredentials = await this.hasValidCredentials();
|
||||
if (!hasCredentials) {
|
||||
baseUsage.error = 'Cursor credentials not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch usage summary
|
||||
const usageSummary = await this.makeRequest<CursorUsageSummary>(USAGE_SUMMARY_ENDPOINT);
|
||||
if (!usageSummary) {
|
||||
baseUsage.error = 'Failed to fetch Cursor usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse plan usage
|
||||
if (usageSummary.planUsage) {
|
||||
const planWindow: UsageWindow = {
|
||||
name: 'Plan Usage',
|
||||
usedPercent: usageSummary.planUsage.percent || 0,
|
||||
resetsAt: usageSummary.planUsage.resetAt || '',
|
||||
resetText: usageSummary.planUsage.resetAt
|
||||
? this.formatResetTime(usageSummary.planUsage.resetAt)
|
||||
: '',
|
||||
};
|
||||
baseUsage.primary = planWindow;
|
||||
baseUsage.planUsage = planWindow;
|
||||
}
|
||||
|
||||
// Parse on-demand usage
|
||||
if (usageSummary.onDemandUsage) {
|
||||
const onDemandWindow: UsageWindow = {
|
||||
name: 'On-Demand Usage',
|
||||
usedPercent: usageSummary.onDemandUsage.percent || 0,
|
||||
resetsAt: usageSummary.billingCycleEnd || '',
|
||||
resetText: usageSummary.billingCycleEnd
|
||||
? this.formatResetTime(usageSummary.billingCycleEnd)
|
||||
: '',
|
||||
};
|
||||
baseUsage.secondary = onDemandWindow;
|
||||
baseUsage.onDemandUsage = onDemandWindow;
|
||||
|
||||
if (usageSummary.onDemandUsage.costUsd !== undefined) {
|
||||
baseUsage.onDemandCostUsd = usageSummary.onDemandUsage.costUsd;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse billing cycle end
|
||||
if (usageSummary.billingCycleEnd) {
|
||||
baseUsage.billingCycleEnd = usageSummary.billingCycleEnd;
|
||||
}
|
||||
|
||||
// Parse plan type
|
||||
if (usageSummary.plan) {
|
||||
baseUsage.plan = {
|
||||
type: usageSummary.plan,
|
||||
displayName: this.formatPlanName(usageSummary.plan),
|
||||
isPaid: usageSummary.plan.toLowerCase() !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ Cursor usage: Plan=${baseUsage.planUsage?.usedPercent || 0}%, ` +
|
||||
`OnDemand=${baseUsage.onDemandUsage?.usedPercent || 0}%`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format plan name for display
|
||||
*/
|
||||
private formatPlanName(plan: string): string {
|
||||
const planMap: Record<string, string> = {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
business: 'Business',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
return planMap[plan.toLowerCase()] || plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials (useful for logout)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedAccessToken = null;
|
||||
this.cachedSessionCookie = null;
|
||||
}
|
||||
}
|
||||
@@ -169,9 +169,10 @@ export class EventHookService {
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: payload.featureName,
|
||||
featureName: featureName || payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||
error: payload.error || payload.message,
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* Gemini Usage Service
|
||||
*
|
||||
* Fetches usage data from Google's Gemini/Cloud Code API using OAuth credentials.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. OAuth credentials from ~/.gemini/oauth_creds.json
|
||||
* 2. API key (limited - only supports API calls, not quota info)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota - Quota info
|
||||
* - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist - Tier detection
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { GeminiProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GeminiUsage');
|
||||
|
||||
// Gemini API endpoints
|
||||
const QUOTA_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
const TOKEN_REFRESH_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
// Gemini CLI client credentials (from Gemini CLI installation)
|
||||
// These are embedded in the Gemini CLI and are public
|
||||
const GEMINI_CLIENT_ID =
|
||||
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
|
||||
const GEMINI_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty';
|
||||
|
||||
interface GeminiOAuthCreds {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token?: string;
|
||||
expiry_date: number;
|
||||
}
|
||||
|
||||
interface GeminiQuotaResponse {
|
||||
quotas?: Array<{
|
||||
remainingFraction: number;
|
||||
resetTime: string;
|
||||
modelId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GeminiCodeAssistResponse {
|
||||
tier?: string;
|
||||
claims?: {
|
||||
hd?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GeminiUsageService {
|
||||
private cachedCreds: GeminiOAuthCreds | null = null;
|
||||
private settingsPath = path.join(os.homedir(), '.gemini', 'settings.json');
|
||||
private credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
|
||||
/**
|
||||
* Check if Gemini credentials are available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const creds = await this.getOAuthCreds();
|
||||
return !!creds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication type from settings
|
||||
*/
|
||||
private getAuthType(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(this.settingsPath)) {
|
||||
const content = fs.readFileSync(this.settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
return settings.auth_type || settings.authType || null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read Gemini settings:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth credentials from file
|
||||
*/
|
||||
private async getOAuthCreds(): Promise<GeminiOAuthCreds | null> {
|
||||
// Check auth type - only oauth-personal supports quota API
|
||||
const authType = this.getAuthType();
|
||||
if (authType && authType !== 'oauth-personal') {
|
||||
logger.debug(`Gemini auth type is ${authType}, not oauth-personal - quota API not available`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cached credentials
|
||||
if (this.cachedCreds) {
|
||||
// Check if expired
|
||||
if (this.cachedCreds.expiry_date > Date.now()) {
|
||||
return this.cachedCreds;
|
||||
}
|
||||
// Try to refresh
|
||||
const refreshed = await this.refreshToken(this.cachedCreds.refresh_token);
|
||||
if (refreshed) {
|
||||
this.cachedCreds = refreshed;
|
||||
return this.cachedCreds;
|
||||
}
|
||||
}
|
||||
|
||||
// Load from file
|
||||
try {
|
||||
if (fs.existsSync(this.credsPath)) {
|
||||
const content = fs.readFileSync(this.credsPath, 'utf8');
|
||||
const creds = JSON.parse(content) as GeminiOAuthCreds;
|
||||
|
||||
// Check if expired
|
||||
if (creds.expiry_date && creds.expiry_date <= Date.now()) {
|
||||
// Try to refresh
|
||||
if (creds.refresh_token) {
|
||||
const refreshed = await this.refreshToken(creds.refresh_token);
|
||||
if (refreshed) {
|
||||
this.cachedCreds = refreshed;
|
||||
// Save refreshed credentials
|
||||
this.saveCreds(refreshed);
|
||||
return this.cachedCreds;
|
||||
}
|
||||
}
|
||||
logger.warn('Gemini OAuth token expired and refresh failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cachedCreds = creds;
|
||||
return this.cachedCreds;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read Gemini OAuth credentials:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth token
|
||||
*/
|
||||
private async refreshToken(refreshToken: string): Promise<GeminiOAuthCreds | null> {
|
||||
try {
|
||||
const response = await fetch(TOKEN_REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: GEMINI_CLIENT_ID,
|
||||
client_secret: GEMINI_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Token refresh failed: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
id_token?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: refreshToken,
|
||||
id_token: data.id_token,
|
||||
expiry_date: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh Gemini token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to file
|
||||
*/
|
||||
private saveCreds(creds: GeminiOAuthCreds): void {
|
||||
try {
|
||||
const dir = path.dirname(this.credsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.credsPath, JSON.stringify(creds, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to save Gemini credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Gemini API
|
||||
*/
|
||||
private async makeRequest<T>(url: string, body?: unknown): Promise<T | null> {
|
||||
const creds = await this.getOAuthCreds();
|
||||
if (!creds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Clear cached credentials on auth failure
|
||||
this.cachedCreds = null;
|
||||
logger.warn('Gemini API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`Gemini API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Gemini API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from Gemini
|
||||
*/
|
||||
async fetchUsageData(): Promise<GeminiProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting Gemini usage fetch...');
|
||||
|
||||
const baseUsage: GeminiProviderUsage = {
|
||||
providerId: 'gemini',
|
||||
providerName: 'Gemini',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Check if credentials are available
|
||||
const creds = await this.getOAuthCreds();
|
||||
if (!creds) {
|
||||
baseUsage.error = 'Gemini OAuth credentials not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Fetch quota information
|
||||
const quotaResponse = await this.makeRequest<GeminiQuotaResponse>(QUOTA_ENDPOINT, {
|
||||
projectId: '-', // Use default project
|
||||
});
|
||||
|
||||
if (quotaResponse?.quotas && quotaResponse.quotas.length > 0) {
|
||||
baseUsage.available = true;
|
||||
|
||||
const primaryQuota = quotaResponse.quotas[0];
|
||||
|
||||
// Convert remaining fraction to used percent
|
||||
const usedPercent = Math.round((1 - (primaryQuota.remainingFraction || 0)) * 100);
|
||||
|
||||
const quotaWindow: UsageWindow = {
|
||||
name: 'Quota',
|
||||
usedPercent,
|
||||
resetsAt: primaryQuota.resetTime || '',
|
||||
resetText: primaryQuota.resetTime ? this.formatResetTime(primaryQuota.resetTime) : '',
|
||||
};
|
||||
|
||||
baseUsage.primary = quotaWindow;
|
||||
baseUsage.remainingFraction = primaryQuota.remainingFraction;
|
||||
baseUsage.modelId = primaryQuota.modelId;
|
||||
}
|
||||
|
||||
// Fetch tier information
|
||||
const codeAssistResponse = await this.makeRequest<GeminiCodeAssistResponse>(
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
{
|
||||
metadata: {
|
||||
ide: 'automaker',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (codeAssistResponse?.tier) {
|
||||
baseUsage.tierType = codeAssistResponse.tier;
|
||||
|
||||
// Determine plan info from tier
|
||||
const tierMap: Record<string, { type: string; displayName: string; isPaid: boolean }> = {
|
||||
'standard-tier': { type: 'paid', displayName: 'Paid', isPaid: true },
|
||||
'free-tier': {
|
||||
type: codeAssistResponse.claims?.hd ? 'workspace' : 'free',
|
||||
displayName: codeAssistResponse.claims?.hd ? 'Workspace' : 'Free',
|
||||
isPaid: false,
|
||||
},
|
||||
'legacy-tier': { type: 'legacy', displayName: 'Legacy', isPaid: false },
|
||||
};
|
||||
|
||||
const tierInfo = tierMap[codeAssistResponse.tier] || {
|
||||
type: codeAssistResponse.tier,
|
||||
displayName: codeAssistResponse.tier,
|
||||
isPaid: false,
|
||||
};
|
||||
|
||||
baseUsage.plan = tierInfo;
|
||||
}
|
||||
|
||||
if (baseUsage.available) {
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ Gemini usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
|
||||
`tier=${baseUsage.tierType || 'unknown'}`
|
||||
);
|
||||
} else {
|
||||
baseUsage.error = 'Failed to fetch Gemini quota data';
|
||||
}
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `Resets in ${minutes}m`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedCreds = null;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* GLM (z.AI) Usage Service
|
||||
*
|
||||
* Fetches usage data from z.AI's API.
|
||||
* GLM is a Claude-compatible provider offered by z.AI.
|
||||
*
|
||||
* Authentication:
|
||||
* - API Token from provider config or GLM_API_KEY environment variable
|
||||
*
|
||||
* Note: z.AI's API may not expose a dedicated usage endpoint.
|
||||
* This service checks for API availability and reports basic status.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { GLMProviderUsage, ClaudeCompatibleProvider } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('GLMUsage');
|
||||
|
||||
// GLM API base (z.AI)
|
||||
const GLM_API_BASE = 'https://api.z.ai';
|
||||
|
||||
export class GLMUsageService {
|
||||
private providerConfig: ClaudeCompatibleProvider | null = null;
|
||||
private cachedApiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the provider config (called from settings)
|
||||
*/
|
||||
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
|
||||
this.providerConfig = config;
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if GLM is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const apiKey = this.getApiKey();
|
||||
return !!apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from various sources
|
||||
*/
|
||||
private getApiKey(): string | null {
|
||||
if (this.cachedApiKey) {
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.GLM_API_KEY) {
|
||||
this.cachedApiKey = process.env.GLM_API_KEY;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 2. Check provider config
|
||||
if (this.providerConfig?.apiKey) {
|
||||
this.cachedApiKey = this.providerConfig.apiKey;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from GLM
|
||||
*
|
||||
* Note: z.AI may not have a public usage API.
|
||||
* This returns basic availability status.
|
||||
*/
|
||||
async fetchUsageData(): Promise<GLMProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting GLM usage fetch...');
|
||||
|
||||
const baseUsage: GLMProviderUsage = {
|
||||
providerId: 'glm',
|
||||
providerName: 'z.AI GLM',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
baseUsage.error = 'GLM API key not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// GLM/z.AI is available if we have an API key
|
||||
// z.AI doesn't appear to have a public usage endpoint
|
||||
baseUsage.available = true;
|
||||
|
||||
// Check if API key is valid by making a simple request
|
||||
try {
|
||||
const baseUrl = this.providerConfig?.baseUrl || GLM_API_BASE;
|
||||
const response = await fetch(`${baseUrl}/api/anthropic/v1/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'GLM-4.7',
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
|
||||
// We just want to check if auth works, not actually make a request
|
||||
// A 400 with invalid request is fine - it means auth worked
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
baseUsage.available = false;
|
||||
baseUsage.error = 'GLM API authentication failed';
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error or other issue - still mark as available since we have the key
|
||||
logger.debug('GLM API check failed (may be fine):', error);
|
||||
}
|
||||
|
||||
// Note: z.AI doesn't appear to expose usage metrics via API
|
||||
// Users should check their z.AI dashboard for detailed usage
|
||||
if (baseUsage.available) {
|
||||
baseUsage.plan = {
|
||||
type: 'api',
|
||||
displayName: 'API Access',
|
||||
isPaid: true,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`[fetchUsageData] GLM available: ${baseUsage.available}`);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
SendMessageOptions,
|
||||
PromptCategory,
|
||||
IdeationPrompt,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
import {
|
||||
getIdeationDir,
|
||||
getIdeasDir,
|
||||
@@ -32,8 +34,10 @@ import {
|
||||
getIdeationSessionsDir,
|
||||
getIdeationSessionPath,
|
||||
getIdeationAnalysisPath,
|
||||
getAppSpecPath,
|
||||
ensureIdeationDir,
|
||||
} from '@automaker/platform';
|
||||
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
|
||||
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
@@ -638,8 +642,12 @@ export class IdeationService {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count: number = 10
|
||||
count: number = 10,
|
||||
contextSources?: IdeationContextSources
|
||||
): Promise<AnalysisSuggestion[]> {
|
||||
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
|
||||
// Merge with defaults for backward compatibility
|
||||
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
// Get the prompt
|
||||
@@ -656,16 +664,26 @@ export class IdeationService {
|
||||
});
|
||||
|
||||
try {
|
||||
// Load context files
|
||||
// Load context files (respecting toggle settings)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
includeContextFiles: sources.useContextFiles,
|
||||
includeMemory: sources.useMemoryFiles,
|
||||
});
|
||||
|
||||
// Build context from multiple sources
|
||||
let contextPrompt = contextResult.formattedPrompt;
|
||||
|
||||
// If no context files, try to gather basic project info
|
||||
// Add app spec context if enabled
|
||||
if (sources.useAppSpec) {
|
||||
const appSpecContext = await this.buildAppSpecContext(projectPath);
|
||||
if (appSpecContext) {
|
||||
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
|
||||
}
|
||||
}
|
||||
|
||||
// If no context was found, try to gather basic project info
|
||||
if (!contextPrompt) {
|
||||
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
||||
if (projectInfo) {
|
||||
@@ -673,8 +691,11 @@ export class IdeationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Gather existing features and ideas to prevent duplicates
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
|
||||
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
|
||||
includeFeatures: sources.useExistingFeatures,
|
||||
includeIdeas: sources.useExistingIdeas,
|
||||
});
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
||||
@@ -684,7 +705,7 @@ export class IdeationService {
|
||||
prompts.ideation.suggestionsSystemPrompt,
|
||||
contextPrompt,
|
||||
category,
|
||||
count,
|
||||
suggestionCount,
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
@@ -751,7 +772,11 @@ export class IdeationService {
|
||||
}
|
||||
|
||||
// Parse the response into structured suggestions
|
||||
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
|
||||
const suggestions = this.parseSuggestionsFromResponse(
|
||||
responseText,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
// Emit complete event
|
||||
this.events.emit('ideation:suggestions', {
|
||||
@@ -814,40 +839,47 @@ ${contextSection}${existingWorkSection}`;
|
||||
*/
|
||||
private parseSuggestionsFromResponse(
|
||||
response: string,
|
||||
category: IdeaCategory
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
logger.warn('No JSON array found in response, falling back to text parsing');
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
return parsed.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}));
|
||||
return parsed
|
||||
.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}))
|
||||
.slice(0, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: parse text response into suggestions
|
||||
*/
|
||||
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
|
||||
private parseTextResponse(
|
||||
response: string,
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
const suggestions: AnalysisSuggestion[] = [];
|
||||
|
||||
// Try to find numbered items or headers
|
||||
@@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5); // Max 5 suggestions
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1345,6 +1377,68 @@ ${contextSection}${existingWorkSection}`;
|
||||
return descriptions[category] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from app_spec.txt for suggestion generation
|
||||
* Extracts project name, overview, capabilities, and implemented features
|
||||
*/
|
||||
private async buildAppSpecContext(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('## App Specification');
|
||||
|
||||
// Extract project name
|
||||
const projectNames = extractXmlElements(specContent, 'project_name');
|
||||
if (projectNames.length > 0 && projectNames[0]) {
|
||||
parts.push(`**Project:** ${projectNames[0]}`);
|
||||
}
|
||||
|
||||
// Extract overview
|
||||
const overviews = extractXmlElements(specContent, 'overview');
|
||||
if (overviews.length > 0 && overviews[0]) {
|
||||
parts.push(`**Overview:** ${overviews[0]}`);
|
||||
}
|
||||
|
||||
// Extract core capabilities
|
||||
const capabilities = extractXmlElements(specContent, 'capability');
|
||||
if (capabilities.length > 0) {
|
||||
parts.push('**Core Capabilities:**');
|
||||
for (const cap of capabilities) {
|
||||
parts.push(`- ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract implemented features
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
if (implementedFeatures.length > 0) {
|
||||
parts.push('**Implemented Features:**');
|
||||
for (const feature of implementedFeatures) {
|
||||
if (feature.description) {
|
||||
parts.push(`- ${feature.name}: ${feature.description}`);
|
||||
} else {
|
||||
parts.push(`- ${feature.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return content if we extracted something meaningful
|
||||
if (parts.length > 1) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
// If file doesn't exist, return empty string silently
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
// For other errors, log and return empty string
|
||||
logger.warn('Failed to build app spec context:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather basic project information for context when no context files exist
|
||||
*/
|
||||
@@ -1440,11 +1534,15 @@ ${contextSection}${existingWorkSection}`;
|
||||
* Gather existing features and ideas to prevent duplicate suggestions
|
||||
* Returns a concise list of titles grouped by status to avoid polluting context
|
||||
*/
|
||||
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
|
||||
private async gatherExistingWorkContext(
|
||||
projectPath: string,
|
||||
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
|
||||
): Promise<string> {
|
||||
const { includeFeatures = true, includeIdeas = true } = options ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load existing features from the board
|
||||
if (this.featureLoader) {
|
||||
if (includeFeatures && this.featureLoader) {
|
||||
try {
|
||||
const features = await this.featureLoader.getAll(projectPath);
|
||||
if (features.length > 0) {
|
||||
@@ -1492,34 +1590,36 @@ ${contextSection}${existingWorkSection}`;
|
||||
}
|
||||
|
||||
// Load existing ideas
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
if (includeIdeas) {
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* MiniMax Usage Service
|
||||
*
|
||||
* Fetches usage data from MiniMax's coding plan API.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Authentication methods:
|
||||
* 1. API Token (MINIMAX_API_KEY environment variable or provider config)
|
||||
* 2. Cookie-based authentication (from platform login)
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET https://api.minimax.io/v1/coding_plan/remains - Token-based usage
|
||||
* - GET https://platform.minimax.io/v1/api/openplatform/coding_plan/remains - Fallback
|
||||
*
|
||||
* For China mainland: platform.minimaxi.com
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MiniMaxProviderUsage, UsageWindow, ClaudeCompatibleProvider } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('MiniMaxUsage');
|
||||
|
||||
// MiniMax API endpoints
|
||||
const MINIMAX_API_BASE = 'https://api.minimax.io';
|
||||
const MINIMAX_PLATFORM_BASE = 'https://platform.minimax.io';
|
||||
const MINIMAX_CHINA_BASE = 'https://platform.minimaxi.com';
|
||||
|
||||
const CODING_PLAN_ENDPOINT = '/v1/coding_plan/remains';
|
||||
const PLATFORM_CODING_PLAN_ENDPOINT = '/v1/api/openplatform/coding_plan/remains';
|
||||
|
||||
interface MiniMaxCodingPlanResponse {
|
||||
base_resp?: {
|
||||
status_code?: number;
|
||||
status_msg?: string;
|
||||
};
|
||||
model_remains?: Array<{
|
||||
model: string;
|
||||
used: number;
|
||||
total: number;
|
||||
}>;
|
||||
remains_time?: number; // Seconds until reset
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export class MiniMaxUsageService {
|
||||
private providerConfig: ClaudeCompatibleProvider | null = null;
|
||||
private cachedApiKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the provider config (called from settings)
|
||||
*/
|
||||
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
|
||||
this.providerConfig = config;
|
||||
this.cachedApiKey = null; // Clear cache when config changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MiniMax is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const apiKey = this.getApiKey();
|
||||
return !!apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from various sources
|
||||
*/
|
||||
private getApiKey(): string | null {
|
||||
if (this.cachedApiKey) {
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 1. Check environment variable
|
||||
if (process.env.MINIMAX_API_KEY) {
|
||||
this.cachedApiKey = process.env.MINIMAX_API_KEY;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
// 2. Check provider config
|
||||
if (this.providerConfig?.apiKey) {
|
||||
this.cachedApiKey = this.providerConfig.apiKey;
|
||||
return this.cachedApiKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should use China endpoint
|
||||
*/
|
||||
private isChina(): boolean {
|
||||
if (this.providerConfig?.baseUrl) {
|
||||
return this.providerConfig.baseUrl.includes('minimaxi.com');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to MiniMax API
|
||||
*/
|
||||
private async makeRequest<T>(url: string): Promise<T | null> {
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.cachedApiKey = null;
|
||||
logger.warn('MiniMax API authentication failed');
|
||||
return null;
|
||||
}
|
||||
logger.error(`MiniMax API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from MiniMax API:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from MiniMax
|
||||
*/
|
||||
async fetchUsageData(): Promise<MiniMaxProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting MiniMax usage fetch...');
|
||||
|
||||
const baseUsage: MiniMaxProviderUsage = {
|
||||
providerId: 'minimax',
|
||||
providerName: 'MiniMax',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const apiKey = this.getApiKey();
|
||||
if (!apiKey) {
|
||||
baseUsage.error = 'MiniMax API key not available';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Determine the correct endpoint
|
||||
const isChina = this.isChina();
|
||||
const baseUrl = isChina ? MINIMAX_CHINA_BASE : MINIMAX_API_BASE;
|
||||
const endpoint = `${baseUrl}${CODING_PLAN_ENDPOINT}`;
|
||||
|
||||
// Fetch coding plan data
|
||||
let codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(endpoint);
|
||||
|
||||
// Try fallback endpoint if primary fails
|
||||
if (!codingPlan) {
|
||||
const platformBase = isChina ? MINIMAX_CHINA_BASE : MINIMAX_PLATFORM_BASE;
|
||||
const fallbackEndpoint = `${platformBase}${PLATFORM_CODING_PLAN_ENDPOINT}`;
|
||||
codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(fallbackEndpoint);
|
||||
}
|
||||
|
||||
if (!codingPlan) {
|
||||
baseUsage.error = 'Failed to fetch MiniMax usage data';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// Check for error response
|
||||
if (codingPlan.base_resp?.status_code && codingPlan.base_resp.status_code !== 0) {
|
||||
baseUsage.error = codingPlan.base_resp.status_msg || 'MiniMax API error';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
baseUsage.available = true;
|
||||
|
||||
// Parse model remains
|
||||
if (codingPlan.model_remains && codingPlan.model_remains.length > 0) {
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
|
||||
for (const model of codingPlan.model_remains) {
|
||||
totalUsed += model.used;
|
||||
totalLimit += model.total;
|
||||
}
|
||||
|
||||
const usedPercent = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0;
|
||||
|
||||
// Calculate reset time
|
||||
const resetsAt = codingPlan.remains_time
|
||||
? new Date(Date.now() + codingPlan.remains_time * 1000).toISOString()
|
||||
: codingPlan.end_time || '';
|
||||
|
||||
const usageWindow: UsageWindow = {
|
||||
name: 'Coding Plan',
|
||||
usedPercent,
|
||||
resetsAt,
|
||||
resetText: resetsAt ? this.formatResetTime(resetsAt) : '',
|
||||
used: totalUsed,
|
||||
limit: totalLimit,
|
||||
};
|
||||
|
||||
baseUsage.primary = usageWindow;
|
||||
baseUsage.tokenRemains = totalLimit - totalUsed;
|
||||
baseUsage.totalTokens = totalLimit;
|
||||
}
|
||||
|
||||
// Parse plan times
|
||||
if (codingPlan.start_time) {
|
||||
baseUsage.planStartTime = codingPlan.start_time;
|
||||
}
|
||||
if (codingPlan.end_time) {
|
||||
baseUsage.planEndTime = codingPlan.end_time;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] ✓ MiniMax usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
|
||||
`${baseUsage.tokenRemains || 0} tokens remaining`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAt: string): string {
|
||||
try {
|
||||
const date = new Date(resetAt);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Resets in ${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `Resets in ${hours}h`;
|
||||
}
|
||||
return 'Resets soon';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedApiKey = null;
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* OpenCode Usage Service
|
||||
*
|
||||
* Fetches usage data from OpenCode's server API.
|
||||
* Based on CodexBar reference implementation.
|
||||
*
|
||||
* Note: OpenCode usage tracking is limited as they use a proprietary
|
||||
* server function API that requires browser cookies for authentication.
|
||||
* This service provides basic status checking based on local config.
|
||||
*
|
||||
* API Endpoints (require browser cookies):
|
||||
* - POST https://opencode.ai/_server - Server functions
|
||||
* - workspaces: Get workspace info
|
||||
* - subscription.get: Get usage data
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { OpenCodeProviderUsage, UsageWindow } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('OpenCodeUsage');
|
||||
|
||||
// OpenCode config locations
|
||||
const OPENCODE_CONFIG_PATHS = [
|
||||
path.join(os.homedir(), '.opencode', 'config.json'),
|
||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
||||
];
|
||||
|
||||
interface OpenCodeConfig {
|
||||
workspaceId?: string;
|
||||
email?: string;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeUsageData {
|
||||
rollingUsage?: {
|
||||
usagePercent: number;
|
||||
resetInSec: number;
|
||||
};
|
||||
weeklyUsage?: {
|
||||
usagePercent: number;
|
||||
resetInSec: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeUsageService {
|
||||
private cachedConfig: OpenCodeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Check if OpenCode is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const config = this.getConfig();
|
||||
return !!config?.authenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenCode config from disk
|
||||
*/
|
||||
private getConfig(): OpenCodeConfig | null {
|
||||
if (this.cachedConfig) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
// Check environment variable for workspace ID
|
||||
if (process.env.OPENCODE_WORKSPACE_ID) {
|
||||
this.cachedConfig = {
|
||||
workspaceId: process.env.OPENCODE_WORKSPACE_ID,
|
||||
authenticated: true,
|
||||
};
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
// Check config files
|
||||
for (const configPath of OPENCODE_CONFIG_PATHS) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(content) as OpenCodeConfig;
|
||||
this.cachedConfig = config;
|
||||
return this.cachedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to read OpenCode config from ${configPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage data from OpenCode
|
||||
*
|
||||
* Note: OpenCode's usage API requires browser cookies which we don't have access to.
|
||||
* This implementation returns basic availability status.
|
||||
* For full usage tracking, users should check the OpenCode dashboard.
|
||||
*/
|
||||
async fetchUsageData(): Promise<OpenCodeProviderUsage> {
|
||||
logger.info('[fetchUsageData] Starting OpenCode usage fetch...');
|
||||
|
||||
const baseUsage: OpenCodeProviderUsage = {
|
||||
providerId: 'opencode',
|
||||
providerName: 'OpenCode',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const config = this.getConfig();
|
||||
if (!config) {
|
||||
baseUsage.error = 'OpenCode not configured';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
if (!config.authenticated) {
|
||||
baseUsage.error = 'OpenCode not authenticated';
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
// OpenCode is available but we can't get detailed usage without browser cookies
|
||||
baseUsage.available = true;
|
||||
baseUsage.workspaceId = config.workspaceId;
|
||||
|
||||
// Note: Full usage tracking requires browser cookie authentication
|
||||
// which is not available in a server-side context.
|
||||
// Users should check the OpenCode dashboard for detailed usage.
|
||||
baseUsage.error =
|
||||
'Usage details require browser authentication. Check opencode.ai for details.';
|
||||
|
||||
logger.info(
|
||||
`[fetchUsageData] OpenCode available, workspace: ${config.workspaceId || 'unknown'}`
|
||||
);
|
||||
|
||||
return baseUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached config
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedConfig = null;
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* Provider Usage Tracker
|
||||
*
|
||||
* Unified service that aggregates usage data from all supported AI providers.
|
||||
* Manages caching, polling, and coordination of individual usage services.
|
||||
*
|
||||
* Supported providers:
|
||||
* - Claude (via ClaudeUsageService)
|
||||
* - Codex (via CodexUsageService)
|
||||
* - Cursor (via CursorUsageService)
|
||||
* - Gemini (via GeminiUsageService)
|
||||
* - GitHub Copilot (via CopilotUsageService)
|
||||
* - OpenCode (via OpenCodeUsageService)
|
||||
* - MiniMax (via MiniMaxUsageService)
|
||||
* - GLM (via GLMUsageService)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type {
|
||||
UsageProviderId,
|
||||
ProviderUsage,
|
||||
AllProvidersUsage,
|
||||
ClaudeProviderUsage,
|
||||
CodexProviderUsage,
|
||||
ClaudeCompatibleProvider,
|
||||
} from '@automaker/types';
|
||||
import { ClaudeUsageService } from './claude-usage-service.js';
|
||||
import { CodexUsageService, type CodexUsageData } from './codex-usage-service.js';
|
||||
import { CursorUsageService } from './cursor-usage-service.js';
|
||||
import { GeminiUsageService } from './gemini-usage-service.js';
|
||||
import { CopilotUsageService } from './copilot-usage-service.js';
|
||||
import { OpenCodeUsageService } from './opencode-usage-service.js';
|
||||
import { MiniMaxUsageService } from './minimax-usage-service.js';
|
||||
import { GLMUsageService } from './glm-usage-service.js';
|
||||
import type { ClaudeUsage } from '../routes/claude/types.js';
|
||||
|
||||
const logger = createLogger('ProviderUsageTracker');
|
||||
|
||||
// Cache TTL in milliseconds (1 minute)
|
||||
const CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
interface CachedUsage {
|
||||
data: ProviderUsage;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export class ProviderUsageTracker {
|
||||
private claudeService: ClaudeUsageService;
|
||||
private codexService: CodexUsageService;
|
||||
private cursorService: CursorUsageService;
|
||||
private geminiService: GeminiUsageService;
|
||||
private copilotService: CopilotUsageService;
|
||||
private opencodeService: OpenCodeUsageService;
|
||||
private minimaxService: MiniMaxUsageService;
|
||||
private glmService: GLMUsageService;
|
||||
|
||||
private cache: Map<UsageProviderId, CachedUsage> = new Map();
|
||||
private enabledProviders: Set<UsageProviderId> = new Set([
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
]);
|
||||
|
||||
constructor(codexService?: CodexUsageService) {
|
||||
this.claudeService = new ClaudeUsageService();
|
||||
this.codexService = codexService || new CodexUsageService();
|
||||
this.cursorService = new CursorUsageService();
|
||||
this.geminiService = new GeminiUsageService();
|
||||
this.copilotService = new CopilotUsageService();
|
||||
this.opencodeService = new OpenCodeUsageService();
|
||||
this.minimaxService = new MiniMaxUsageService();
|
||||
this.glmService = new GLMUsageService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set enabled providers (called when settings change)
|
||||
*/
|
||||
setEnabledProviders(providers: UsageProviderId[]): void {
|
||||
this.enabledProviders = new Set(providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom provider configs (MiniMax, GLM)
|
||||
*/
|
||||
updateCustomProviderConfigs(providers: ClaudeCompatibleProvider[]): void {
|
||||
const minimaxConfig = providers.find(
|
||||
(p) => p.providerType === 'minimax' && p.enabled !== false
|
||||
);
|
||||
const glmConfig = providers.find((p) => p.providerType === 'glm' && p.enabled !== false);
|
||||
|
||||
this.minimaxService.setProviderConfig(minimaxConfig || null);
|
||||
this.glmService.setProviderConfig(glmConfig || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is enabled
|
||||
*/
|
||||
isProviderEnabled(providerId: UsageProviderId): boolean {
|
||||
return this.enabledProviders.has(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cached data is still fresh
|
||||
*/
|
||||
private isCacheFresh(providerId: UsageProviderId): boolean {
|
||||
const cached = this.cache.get(providerId);
|
||||
if (!cached) return false;
|
||||
return Date.now() - cached.fetchedAt < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data for a provider
|
||||
*/
|
||||
private getCached(providerId: UsageProviderId): ProviderUsage | null {
|
||||
const cached = this.cache.get(providerId);
|
||||
return cached?.data || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached data for a provider
|
||||
*/
|
||||
private setCached(providerId: UsageProviderId, data: ProviderUsage): void {
|
||||
this.cache.set(providerId, {
|
||||
data,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Claude usage to unified format
|
||||
*/
|
||||
private convertClaudeUsage(usage: ClaudeUsage): ClaudeProviderUsage {
|
||||
return {
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
available: true,
|
||||
lastUpdated: usage.lastUpdated,
|
||||
userTimezone: usage.userTimezone,
|
||||
primary: {
|
||||
name: 'Session (5-hour)',
|
||||
usedPercent: usage.sessionPercentage,
|
||||
resetsAt: usage.sessionResetTime,
|
||||
resetText: usage.sessionResetText,
|
||||
},
|
||||
secondary: {
|
||||
name: 'Weekly (All Models)',
|
||||
usedPercent: usage.weeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.weeklyResetText,
|
||||
},
|
||||
sessionWindow: {
|
||||
name: 'Session (5-hour)',
|
||||
usedPercent: usage.sessionPercentage,
|
||||
resetsAt: usage.sessionResetTime,
|
||||
resetText: usage.sessionResetText,
|
||||
},
|
||||
weeklyWindow: {
|
||||
name: 'Weekly (All Models)',
|
||||
usedPercent: usage.weeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.weeklyResetText,
|
||||
},
|
||||
sonnetWindow: {
|
||||
name: 'Weekly (Sonnet)',
|
||||
usedPercent: usage.sonnetWeeklyPercentage,
|
||||
resetsAt: usage.weeklyResetTime,
|
||||
resetText: usage.sonnetResetText,
|
||||
},
|
||||
cost:
|
||||
usage.costUsed !== null
|
||||
? {
|
||||
used: usage.costUsed,
|
||||
limit: usage.costLimit,
|
||||
currency: usage.costCurrency || 'USD',
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Codex usage to unified format
|
||||
*/
|
||||
private convertCodexUsage(usage: CodexUsageData): CodexProviderUsage {
|
||||
const result: CodexProviderUsage = {
|
||||
providerId: 'codex',
|
||||
providerName: 'Codex',
|
||||
available: true,
|
||||
lastUpdated: usage.lastUpdated,
|
||||
planType: usage.rateLimits?.planType,
|
||||
};
|
||||
|
||||
if (usage.rateLimits?.primary) {
|
||||
result.primary = {
|
||||
name: `${usage.rateLimits.primary.windowDurationMins}min Window`,
|
||||
usedPercent: usage.rateLimits.primary.usedPercent,
|
||||
resetsAt: new Date(usage.rateLimits.primary.resetsAt * 1000).toISOString(),
|
||||
resetText: this.formatResetTime(usage.rateLimits.primary.resetsAt * 1000),
|
||||
windowDurationMins: usage.rateLimits.primary.windowDurationMins,
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.rateLimits?.secondary) {
|
||||
result.secondary = {
|
||||
name: `${usage.rateLimits.secondary.windowDurationMins}min Window`,
|
||||
usedPercent: usage.rateLimits.secondary.usedPercent,
|
||||
resetsAt: new Date(usage.rateLimits.secondary.resetsAt * 1000).toISOString(),
|
||||
resetText: this.formatResetTime(usage.rateLimits.secondary.resetsAt * 1000),
|
||||
windowDurationMins: usage.rateLimits.secondary.windowDurationMins,
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.rateLimits?.planType) {
|
||||
result.plan = {
|
||||
type: usage.rateLimits.planType,
|
||||
displayName:
|
||||
usage.rateLimits.planType.charAt(0).toUpperCase() + usage.rateLimits.planType.slice(1),
|
||||
isPaid: usage.rateLimits.planType !== 'free',
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(resetAtMs: number): string {
|
||||
const diff = resetAtMs - Date.now();
|
||||
if (diff < 0) return 'Expired';
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `Resets in ${days}d ${hours % 24}h`;
|
||||
if (hours > 0) return `Resets in ${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `Resets in ${minutes}m`;
|
||||
return 'Resets soon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage for a specific provider
|
||||
*/
|
||||
async fetchProviderUsage(
|
||||
providerId: UsageProviderId,
|
||||
forceRefresh = false
|
||||
): Promise<ProviderUsage | null> {
|
||||
// Check cache first
|
||||
if (!forceRefresh && this.isCacheFresh(providerId)) {
|
||||
return this.getCached(providerId);
|
||||
}
|
||||
|
||||
try {
|
||||
let usage: ProviderUsage | null = null;
|
||||
|
||||
switch (providerId) {
|
||||
case 'claude': {
|
||||
if (await this.claudeService.isAvailable()) {
|
||||
const claudeUsage = await this.claudeService.fetchUsageData();
|
||||
usage = this.convertClaudeUsage(claudeUsage);
|
||||
} else {
|
||||
usage = {
|
||||
providerId: 'claude',
|
||||
providerName: 'Claude',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Claude CLI not available',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex': {
|
||||
if (await this.codexService.isAvailable()) {
|
||||
const codexUsage = await this.codexService.fetchUsageData();
|
||||
usage = this.convertCodexUsage(codexUsage);
|
||||
} else {
|
||||
usage = {
|
||||
providerId: 'codex',
|
||||
providerName: 'Codex',
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Codex CLI not available',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cursor': {
|
||||
usage = await this.cursorService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gemini': {
|
||||
usage = await this.geminiService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'copilot': {
|
||||
usage = await this.copilotService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'opencode': {
|
||||
usage = await this.opencodeService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'minimax': {
|
||||
usage = await this.minimaxService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'glm': {
|
||||
usage = await this.glmService.fetchUsageData();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
this.setCached(providerId, usage);
|
||||
}
|
||||
|
||||
return usage;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch usage for ${providerId}:`, error);
|
||||
return {
|
||||
providerId,
|
||||
providerName: this.getProviderName(providerId),
|
||||
available: false,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
} as ProviderUsage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name
|
||||
*/
|
||||
private getProviderName(providerId: UsageProviderId): string {
|
||||
const names: Record<UsageProviderId, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
cursor: 'Cursor',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'GitHub Copilot',
|
||||
opencode: 'OpenCode',
|
||||
minimax: 'MiniMax',
|
||||
glm: 'z.AI GLM',
|
||||
};
|
||||
return names[providerId] || providerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage for all enabled providers
|
||||
*/
|
||||
async fetchAllUsage(forceRefresh = false): Promise<AllProvidersUsage> {
|
||||
const providers: Partial<Record<UsageProviderId, ProviderUsage>> = {};
|
||||
const errors: Array<{ providerId: UsageProviderId; message: string }> = [];
|
||||
|
||||
// Fetch all enabled providers in parallel
|
||||
const enabledList = Array.from(this.enabledProviders);
|
||||
const results = await Promise.allSettled(
|
||||
enabledList.map((providerId) => this.fetchProviderUsage(providerId, forceRefresh))
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const providerId = enabledList[index];
|
||||
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
providers[providerId] = result.value;
|
||||
if (result.value.error) {
|
||||
errors.push({
|
||||
providerId,
|
||||
message: result.value.error,
|
||||
});
|
||||
}
|
||||
} else if (result.status === 'rejected') {
|
||||
errors.push({
|
||||
providerId,
|
||||
message: result.reason?.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
providers,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability for all providers
|
||||
*/
|
||||
async checkAvailability(): Promise<Record<UsageProviderId, boolean>> {
|
||||
const availability: Record<string, boolean> = {};
|
||||
|
||||
const checks = await Promise.allSettled([
|
||||
this.claudeService.isAvailable(),
|
||||
this.codexService.isAvailable(),
|
||||
this.cursorService.isAvailable(),
|
||||
this.geminiService.isAvailable(),
|
||||
this.copilotService.isAvailable(),
|
||||
this.opencodeService.isAvailable(),
|
||||
this.minimaxService.isAvailable(),
|
||||
this.glmService.isAvailable(),
|
||||
]);
|
||||
|
||||
const providerIds: UsageProviderId[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'cursor',
|
||||
'gemini',
|
||||
'copilot',
|
||||
'opencode',
|
||||
'minimax',
|
||||
'glm',
|
||||
];
|
||||
|
||||
checks.forEach((result, index) => {
|
||||
availability[providerIds[index]] =
|
||||
result.status === 'fulfilled' ? result.value : false;
|
||||
});
|
||||
|
||||
return availability as Record<UsageProviderId, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
this.claudeService = new ClaudeUsageService(); // Reset Claude service
|
||||
this.cursorService.clearCache();
|
||||
this.geminiService.clearCache();
|
||||
this.copilotService.clearCache();
|
||||
this.opencodeService.clearCache();
|
||||
this.minimaxService.clearCache();
|
||||
this.glmService.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
|
||||
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
|
||||
expect(call.timeout).toBe(
|
||||
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||
|
||||
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
|
||||
|
||||
// Mock the Copilot SDK
|
||||
vi.mock('@github/copilot-sdk', () => ({
|
||||
CopilotClient: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
sessionId: 'test-session',
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child_process with all needed exports
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs (synchronous) for CLI detection (existsSync)
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
readFile: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Import execSync after mocking
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
describe('copilot-provider.ts', () => {
|
||||
let provider: CopilotProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock fs.existsSync for CLI path validation
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// Mock CLI detection to find the CLI
|
||||
// The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows)
|
||||
// to find the CLI path, then validates with fs.existsSync
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
if (cmd.includes('models list')) {
|
||||
return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
provider = new CopilotProvider();
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
it("should return 'copilot' as provider name", () => {
|
||||
expect(provider.getName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliName', () => {
|
||||
it("should return 'copilot' as CLI name", () => {
|
||||
expect(provider.getCliName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsFeature', () => {
|
||||
it('should support tools feature', () => {
|
||||
expect(provider.supportsFeature('tools')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support text feature', () => {
|
||||
expect(provider.supportsFeature('text')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support streaming feature', () => {
|
||||
expect(provider.supportsFeature('streaming')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT support vision feature (not implemented yet)', () => {
|
||||
expect(provider.supportsFeature('vision')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not support unknown feature', () => {
|
||||
expect(provider.supportsFeature('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return static model definitions', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
// All models should have required fields
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.provider).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include copilot- prefix in model IDs', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toMatch(/^copilot-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('should return authenticated status when gh CLI is logged in', async () => {
|
||||
// Set up mocks BEFORE creating provider to ensure CLI detection succeeds
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
expect(status.login).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should return unauthenticated when gh auth fails', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(false);
|
||||
expect(status.method).toBe('none');
|
||||
});
|
||||
|
||||
it('should detect GITHUB_TOKEN environment variable', async () => {
|
||||
process.env.GITHUB_TOKEN = 'test-token';
|
||||
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectInstallation', () => {
|
||||
it('should detect installed CLI', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.2.3';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.detectInstallation();
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.version).toBe('1.2.3');
|
||||
expect(status.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEvent', () => {
|
||||
it('should normalize assistant.message event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Hello, world!' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello, world!' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip assistant.message_delta event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message_delta',
|
||||
data: { delta: 'partial' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_start event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
input: { path: '/test/file.txt' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Read', // Normalized from read_file
|
||||
tool_use_id: 'call-123',
|
||||
input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_end event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
result: 'file content',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-123',
|
||||
content: 'file content',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_end with error', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'bash',
|
||||
toolCallId: 'call-456',
|
||||
error: 'Command failed',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
content: '[ERROR] Command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.idle to success result', () => {
|
||||
const event = { type: 'session.idle' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.error to error event', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: 'Something went wrong' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
error: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unknown event types', () => {
|
||||
const event = { type: 'unknown.event' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('should map authentication errors', () => {
|
||||
const errorInfo = (provider as any).mapError('not authenticated', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map rate limit errors', () => {
|
||||
const errorInfo = (provider as any).mapError('rate limit exceeded', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map model unavailable errors', () => {
|
||||
const errorInfo = (provider as any).mapError('model not available', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map network errors', () => {
|
||||
const errorInfo = (provider as any).mapError('connection refused', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map process crash (exit code 137)', () => {
|
||||
const errorInfo = (provider as any).mapError('', 137);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should return unknown error for unrecognized errors', () => {
|
||||
const errorInfo = (provider as any).mapError('some random error', 1);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN);
|
||||
expect(errorInfo.recoverable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model cache', () => {
|
||||
it('should indicate when cache is empty', () => {
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear model cache', () => {
|
||||
provider.clearModelCache();
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool name normalization', () => {
|
||||
it('should normalize read_file to Read', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'read_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' });
|
||||
});
|
||||
|
||||
it('should normalize write_file to Write', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'write_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' });
|
||||
});
|
||||
|
||||
it('should normalize run_shell to Bash', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'run_shell', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' });
|
||||
});
|
||||
|
||||
it('should normalize search to Grep', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'search', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' });
|
||||
});
|
||||
|
||||
it('should normalize todo_write to TodoWrite', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' });
|
||||
});
|
||||
|
||||
it('should normalize todo content from description', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0]).toMatchObject({
|
||||
content: 'Test task',
|
||||
status: 'pending',
|
||||
activeForm: 'Test task',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map cancelled status to completed', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Cancelled task', status: 'cancelled' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
import { CopilotProvider } from '@/providers/copilot-provider.js';
|
||||
|
||||
describe('provider-factory.ts', () => {
|
||||
let consoleSpy: any;
|
||||
@@ -13,6 +14,7 @@ describe('provider-factory.ts', () => {
|
||||
let detectCodexSpy: any;
|
||||
let detectOpencodeSpy: any;
|
||||
let detectGeminiSpy: any;
|
||||
let detectCopilotSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
@@ -35,6 +37,9 @@ describe('provider-factory.ts', () => {
|
||||
detectGeminiSpy = vi
|
||||
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectCopilotSpy = vi
|
||||
.spyOn(CopilotProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,6 +49,7 @@ describe('provider-factory.ts', () => {
|
||||
detectCodexSpy.mockRestore();
|
||||
detectOpencodeSpy.mockRestore();
|
||||
detectGeminiSpy.mockRestore();
|
||||
detectCopilotSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
@@ -172,9 +178,15 @@ describe('provider-factory.ts', () => {
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should return exactly 5 providers', () => {
|
||||
it('should return exactly 6 providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(5);
|
||||
expect(providers).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include CopilotProvider', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider);
|
||||
expect(hasCopilotProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should include GeminiProvider', () => {
|
||||
@@ -219,7 +231,8 @@ describe('provider-factory.ts', () => {
|
||||
expect(keys).toContain('codex');
|
||||
expect(keys).toContain('opencode');
|
||||
expect(keys).toContain('gemini');
|
||||
expect(keys).toHaveLength(5);
|
||||
expect(keys).toContain('copilot');
|
||||
expect(keys).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include cursor status', async () => {
|
||||
|
||||
@@ -315,4 +315,531 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectOrphanedFeatures', () => {
|
||||
// Helper to mock featureLoader.getAll
|
||||
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { getAll: mockFn };
|
||||
};
|
||||
|
||||
// Helper to mock getExistingBranches
|
||||
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
|
||||
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
|
||||
};
|
||||
|
||||
it('should return empty array when no features have branch names', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when all feature branches exist', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-2',
|
||||
},
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect orphaned features with missing branches', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'deleted-branch',
|
||||
},
|
||||
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
expect(result[0].missingBranch).toBe('deleted-branch');
|
||||
});
|
||||
|
||||
it('should detect multiple orphaned features', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-2',
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
title: 'Feature 3',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'valid-branch',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'valid-branch']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.feature.id)).toContain('f1');
|
||||
expect(result.map((r) => r.feature.id)).toContain('f2');
|
||||
});
|
||||
|
||||
it('should return empty array when getAll throws error', async () => {
|
||||
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore empty branchName strings', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip features whose branchName matches the primary branch', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphaned',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
// Mock getCurrentBranch to return 'main'
|
||||
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
// Only f2 should be orphaned (orphaned branch doesn't exist)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFeatureInterrupted', () => {
|
||||
// Helper to mock updateFeatureStatus
|
||||
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).loadFeature = mockFn;
|
||||
};
|
||||
|
||||
it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should call updateFeatureStatus with reason when provided', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should propagate errors from updateFeatureStatus', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockRejectedValue(new Error('Update failed'));
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
|
||||
'Update failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve pipeline_implementation status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
|
||||
|
||||
// updateFeatureStatus should NOT be called for pipeline statuses
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve pipeline_testing status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve pipeline_review status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark feature as interrupted when loadFeature returns null', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue(null);
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark feature as interrupted for pending status', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllRunningFeaturesInterrupted', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
// Helper to mock updateFeatureStatus
|
||||
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).loadFeature = mockFn;
|
||||
};
|
||||
|
||||
it('should do nothing when no features are running', async () => {
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark a single running feature as interrupted', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark multiple running features as interrupted', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
runningFeaturesMap.set('feature-3', {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(3);
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted');
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark features in parallel', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
runningFeaturesMap.set(`feature-${i}`, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const callOrder: string[] = [];
|
||||
const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
const startTime = Date.now();
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('should continue marking other features when one fails', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const updateMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Failed to update'));
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
// Should not throw even though one feature failed
|
||||
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use provided reason in logging', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted('manual stop');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should use default reason when none provided', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should preserve pipeline statuses for running features', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
runningFeaturesMap.set('feature-3', {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-c',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// feature-1 has in_progress (should be interrupted)
|
||||
// feature-2 has pipeline_testing (should be preserved)
|
||||
// feature-3 has pipeline_implementation (should be preserved)
|
||||
const loadMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_projectPath: string, featureId: string) => {
|
||||
if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' };
|
||||
if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' };
|
||||
if (featureId === 'feature-3')
|
||||
return { id: 'feature-3', status: 'pipeline_implementation' };
|
||||
return null;
|
||||
});
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
// Only feature-1 should be marked as interrupted
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFeatureRunning', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
it('should return false when no features are running', () => {
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when the feature is running', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-123', {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-running feature when others are running', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-other', {
|
||||
featureId: 'feature-other',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly track multiple running features', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-1')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-2')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedTask } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Test the task parsing logic by reimplementing the parsing functions
|
||||
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
||||
*/
|
||||
|
||||
interface ParsedTask {
|
||||
id: string;
|
||||
description: string;
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
@@ -342,4 +335,236 @@ Some other text
|
||||
expect(fullModeOutput).toContain('[SPEC_GENERATED]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSpecFallback - non-Claude model support', () => {
|
||||
/**
|
||||
* Reimplementation of detectSpecFallback for testing
|
||||
* This mirrors the logic in auto-mode-service.ts for detecting specs
|
||||
* when the [SPEC_GENERATED] marker is missing (common with non-Claude models)
|
||||
*/
|
||||
function detectSpecFallback(text: string): boolean {
|
||||
// Check for key structural elements of a spec
|
||||
const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
|
||||
const hasTaskLines = /- \[ \] T\d{3}:/.test(text);
|
||||
|
||||
// Check for common spec sections (case-insensitive)
|
||||
const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
|
||||
const hasTechnicalContext = /technical context/i.test(text);
|
||||
const hasProblemStatement = /problem statement/i.test(text);
|
||||
const hasUserStory = /user story/i.test(text);
|
||||
// Additional patterns for different model outputs
|
||||
const hasGoal = /\*\*Goal\*\*:/i.test(text);
|
||||
const hasSolution = /\*\*Solution\*\*:/i.test(text);
|
||||
const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
|
||||
const hasOverview = /##\s*(overview|summary)/i.test(text);
|
||||
|
||||
// Spec is detected if we have task structure AND at least some spec content
|
||||
const hasTaskStructure = hasTasksBlock || hasTaskLines;
|
||||
const hasSpecContent =
|
||||
hasAcceptanceCriteria ||
|
||||
hasTechnicalContext ||
|
||||
hasProblemStatement ||
|
||||
hasUserStory ||
|
||||
hasGoal ||
|
||||
hasSolution ||
|
||||
hasImplementation ||
|
||||
hasOverview;
|
||||
|
||||
return hasTaskStructure && hasSpecContent;
|
||||
}
|
||||
|
||||
it('should detect spec with tasks block and acceptance criteria', () => {
|
||||
const content = `
|
||||
## Acceptance Criteria
|
||||
- GIVEN a user, WHEN they login, THEN they see the dashboard
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create login form | File: src/Login.tsx
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with task lines and problem statement', () => {
|
||||
const content = `
|
||||
## Problem Statement
|
||||
Users cannot currently log in to the application.
|
||||
|
||||
## Implementation Plan
|
||||
- [ ] T001: Add authentication endpoint
|
||||
- [ ] T002: Create login UI
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Goal section (lite planning mode style)', () => {
|
||||
const content = `
|
||||
**Goal**: Implement user authentication
|
||||
|
||||
**Solution**: Use JWT tokens for session management
|
||||
|
||||
- [ ] T001: Setup auth middleware
|
||||
- [ ] T002: Create token service
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with User Story format', () => {
|
||||
const content = `
|
||||
## User Story
|
||||
As a user, I want to reset my password, so that I can regain access.
|
||||
|
||||
## Technical Context
|
||||
This will modify the auth module.
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Add reset endpoint
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Overview section', () => {
|
||||
const content = `
|
||||
## Overview
|
||||
This feature adds dark mode support.
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Add theme toggle
|
||||
- [ ] T002: Update CSS variables
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Summary section', () => {
|
||||
const content = `
|
||||
## Summary
|
||||
Adding a new dashboard component.
|
||||
|
||||
- [ ] T001: Create dashboard layout
|
||||
- [ ] T002: Add widgets
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation plan', () => {
|
||||
const content = `
|
||||
## Implementation Plan
|
||||
We will add the feature in two phases.
|
||||
|
||||
- [ ] T001: Phase 1 setup
|
||||
- [ ] T002: Phase 2 implementation
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation steps', () => {
|
||||
const content = `
|
||||
## Implementation Steps
|
||||
Follow these steps:
|
||||
|
||||
- [ ] T001: Step one
|
||||
- [ ] T002: Step two
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation approach', () => {
|
||||
const content = `
|
||||
## Implementation Approach
|
||||
We will use a modular approach.
|
||||
|
||||
- [ ] T001: Create modules
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect spec without task structure', () => {
|
||||
const content = `
|
||||
## Problem Statement
|
||||
Users cannot log in.
|
||||
|
||||
## Acceptance Criteria
|
||||
- GIVEN a user, WHEN they try to login, THEN it works
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect spec without spec content sections', () => {
|
||||
const content = `
|
||||
Here are some tasks:
|
||||
|
||||
- [ ] T001: Do something
|
||||
- [ ] T002: Do another thing
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect random text as spec', () => {
|
||||
const content = 'Just some random text without any structure';
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching for spec sections', () => {
|
||||
const content = `
|
||||
## ACCEPTANCE CRITERIA
|
||||
All caps section header
|
||||
|
||||
- [ ] T001: Task
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
|
||||
const content2 = `
|
||||
## acceptance criteria
|
||||
Lower case section header
|
||||
|
||||
- [ ] T001: Task
|
||||
`;
|
||||
expect(detectSpecFallback(content2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect OpenAI-style output without explicit marker', () => {
|
||||
// Non-Claude models may format specs differently but still have the key elements
|
||||
const openAIStyleOutput = `
|
||||
# Feature Specification: User Authentication
|
||||
|
||||
**Goal**: Allow users to securely log into the application
|
||||
|
||||
**Solution**: Implement JWT-based authentication with refresh tokens
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Users can log in with email and password
|
||||
2. Invalid credentials show error message
|
||||
3. Sessions persist across page refreshes
|
||||
|
||||
## Implementation Tasks
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create auth service | File: src/services/auth.ts
|
||||
- [ ] T002: Build login component | File: src/components/Login.tsx
|
||||
- [ ] T003: Add protected routes | File: src/App.tsx
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(openAIStyleOutput)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Gemini-style output without explicit marker', () => {
|
||||
const geminiStyleOutput = `
|
||||
## Overview
|
||||
|
||||
This specification describes the implementation of a user profile page.
|
||||
|
||||
## Technical Context
|
||||
- Framework: React
|
||||
- State: Redux
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] T001: Create ProfilePage component
|
||||
- [ ] T002: Add profile API endpoint
|
||||
- [ ] T003: Style the profile page
|
||||
`;
|
||||
expect(detectSpecFallback(geminiStyleOutput)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
// Create shared mock instances for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateChatOptions = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
}))
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
vi.mock('@automaker/platform');
|
||||
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
|
||||
});
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
createChatOptions: vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
})),
|
||||
createChatOptions: mockCreateChatOptions,
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -786,6 +790,143 @@ describe('IdeationService', () => {
|
||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||
).rejects.toThrow('Prompt non-existent not found');
|
||||
});
|
||||
|
||||
it('should include app spec context when useAppSpec is enabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<overview>A test application for unit testing</overview>
|
||||
<core_capabilities>
|
||||
<capability>User authentication</capability>
|
||||
<capability>Data visualization</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Login System</name>
|
||||
<description>Basic auth with email/password</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
// First call returns app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(mockAppSpec)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Login System');
|
||||
});
|
||||
|
||||
it('should exclude app spec context when useAppSpec is disabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Hidden Project</project_name>
|
||||
<overview>This should not appear</overview>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: false,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
|
||||
});
|
||||
|
||||
it('should handle missing app spec file gracefully', async () => {
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockRejectedValueOnce(enoentError)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Should not log warning for ENOENT
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,13 @@ const eslintConfig = defineConfig([
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
@@ -45,6 +50,8 @@ const eslintConfig = defineConfig([
|
||||
confirm: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
cancelAnimationFrame: 'readonly',
|
||||
alert: 'readonly',
|
||||
// DOM Element Types
|
||||
HTMLElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
@@ -56,6 +63,8 @@ const eslintConfig = defineConfig([
|
||||
HTMLParagraphElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
Element: 'readonly',
|
||||
SVGElement: 'readonly',
|
||||
SVGSVGElement: 'readonly',
|
||||
// Event Types
|
||||
Event: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
@@ -64,14 +73,24 @@ const eslintConfig = defineConfig([
|
||||
CustomEvent: 'readonly',
|
||||
ClipboardEvent: 'readonly',
|
||||
WheelEvent: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
UIEvent: 'readonly',
|
||||
MediaQueryListEvent: 'readonly',
|
||||
DataTransfer: 'readonly',
|
||||
// Web APIs
|
||||
ResizeObserver: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
IntersectionObserver: 'readonly',
|
||||
Audio: 'readonly',
|
||||
HTMLAudioElement: 'readonly',
|
||||
ScrollBehavior: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
XMLHttpRequest: 'readonly',
|
||||
Response: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
RequestCache: 'readonly',
|
||||
// Timers
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
@@ -90,6 +109,8 @@ const eslintConfig = defineConfig([
|
||||
Electron: 'readonly',
|
||||
// Console
|
||||
console: 'readonly',
|
||||
// Vite defines
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
@@ -99,6 +120,13 @@ const eslintConfig = defineConfig([
|
||||
...ts.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-nocheck': 'allow-with-description',
|
||||
minimumDescriptionLength: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"usehooks-ts": "3.1.1",
|
||||
"zod": "^3.24.1 || ^4.0.0",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -169,6 +170,10 @@
|
||||
"from": "server-bundle/node_modules",
|
||||
"to": "server/node_modules"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/libs",
|
||||
"to": "server/libs"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/package.json",
|
||||
"to": "server/package.json"
|
||||
|
||||
@@ -29,7 +29,7 @@ async function killProcessOnPort(port) {
|
||||
try {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Process might have already exited
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ async function killProcessOnPort(port) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// No process on port, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,26 @@ import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||
import { useAppStore } from './store/app-store';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
import './styles/font-imports';
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// Key for localStorage to persist splash screen preference
|
||||
const DISABLE_SPLASH_KEY = 'automaker-disable-splash';
|
||||
|
||||
export default function App() {
|
||||
const disableSplashScreen = useAppStore((state) => state.disableSplashScreen);
|
||||
|
||||
const [showSplash, setShowSplash] = useState(() => {
|
||||
// Check localStorage for user preference (available synchronously)
|
||||
const savedPreference = localStorage.getItem(DISABLE_SPLASH_KEY);
|
||||
if (savedPreference === 'true') {
|
||||
return false;
|
||||
}
|
||||
// Only show splash once per session
|
||||
if (sessionStorage.getItem('automaker-splash-shown')) {
|
||||
return false;
|
||||
@@ -21,6 +33,11 @@ export default function App() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sync the disableSplashScreen setting to localStorage for fast access on next startup
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
|
||||
}, [disableSplashScreen]);
|
||||
|
||||
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
|
||||
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
||||
useEffect(() => {
|
||||
@@ -59,9 +76,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<RouterProvider router={router} />
|
||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</>
|
||||
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
10
apps/ui/src/assets/icons/gemini-icon.svg
Normal file
10
apps/ui/src/assets/icons/gemini-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)"/>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
|
||||
<stop offset="0.0671246" stop-color="#9168C0"/>
|
||||
<stop offset="0.342551" stop-color="#5684D1"/>
|
||||
<stop offset="0.672076" stop-color="#1BA1E3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -68,7 +68,6 @@ export function CodexUsagePopover() {
|
||||
// Use React Query for data fetching with automatic polling
|
||||
const {
|
||||
data: codexUsage,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: queryError,
|
||||
dataUpdatedAt,
|
||||
|
||||
@@ -40,8 +40,6 @@ interface FileBrowserDialogProps {
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -191,7 +191,7 @@ export function NewProjectModal({
|
||||
|
||||
// Use platform-specific path separator
|
||||
const pathSep =
|
||||
typeof window !== 'undefined' && (window as any).electronAPI
|
||||
typeof window !== 'undefined' && window.electronAPI
|
||||
? navigator.platform.indexOf('Win') !== -1
|
||||
? '\\'
|
||||
: '/'
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import { IconPicker } from './icon-picker';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EditProjectDialogProps {
|
||||
project: Project;
|
||||
@@ -25,9 +26,9 @@ interface EditProjectDialogProps {
|
||||
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
|
||||
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
|
||||
const [name, setName] = useState(project.name);
|
||||
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
|
||||
const [icon, setIcon] = useState<string | null>(project.icon || null);
|
||||
const [customIconPath, setCustomIconPath] = useState<string | null>(
|
||||
(project as any).customIconPath || null
|
||||
project.customIconPath || null
|
||||
);
|
||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -36,10 +37,10 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
if (name.trim() !== project.name) {
|
||||
setProjectName(project.id, name.trim());
|
||||
}
|
||||
if (icon !== (project as any).icon) {
|
||||
if (icon !== project.icon) {
|
||||
setProjectIcon(project.id, icon);
|
||||
}
|
||||
if (customIconPath !== (project as any).customIconPath) {
|
||||
if (customIconPath !== project.customIconPath) {
|
||||
setProjectCustomIcon(project.id, customIconPath);
|
||||
}
|
||||
onOpenChange(false);
|
||||
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error(
|
||||
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 2MB for icons)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
// Validate file size (max 5MB for icons - allows animated GIFs)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toast.error(
|
||||
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
file.type,
|
||||
project.path
|
||||
);
|
||||
|
||||
if (result.success && result.path) {
|
||||
setCustomIconPath(result.path);
|
||||
// Clear the Lucide icon when custom icon is set
|
||||
setIcon(null);
|
||||
toast.success('Icon uploaded successfully');
|
||||
} else {
|
||||
toast.error('Failed to upload icon');
|
||||
}
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error('Failed to read file');
|
||||
setIsUploadingIcon(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch {
|
||||
toast.error('Failed to upload icon');
|
||||
setIsUploadingIcon(false);
|
||||
}
|
||||
};
|
||||
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PNG, JPG, GIF or WebP. Max 2MB.
|
||||
PNG, JPG, GIF or WebP. Max 5MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ThemeButtonProps {
|
||||
/** Handler for pointer leave events (used to clear preview) */
|
||||
onPointerLeave: (e: React.PointerEvent) => void;
|
||||
/** Handler for click events (used to select theme) */
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +77,7 @@ const ThemeButton = memo(function ThemeButton({
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onClick={onClick}
|
||||
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
|
||||
isSelected={selectedTheme === option.value}
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
onClick={() => onSelect(option.value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -193,13 +197,11 @@ export function ProjectContextMenu({
|
||||
const {
|
||||
moveProjectToTrash,
|
||||
theme: globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
} = useAppStore();
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -317,13 +319,24 @@ export function ProjectContextMenu({
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
|
||||
// Clear any pending close timeout to prevent race conditions
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Close menu first
|
||||
setShowThemeSubmenu(false);
|
||||
onClose();
|
||||
|
||||
// Then apply theme changes
|
||||
setPreviewTheme(null);
|
||||
const isUsingGlobal = value === USE_GLOBAL_THEME;
|
||||
setTheme(isUsingGlobal ? globalTheme : value);
|
||||
// Only set project theme - don't change global theme
|
||||
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
|
||||
setProjectTheme(project.id, isUsingGlobal ? null : value);
|
||||
setShowThemeSubmenu(false);
|
||||
},
|
||||
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
|
||||
[onClose, project.id, setPreviewTheme, setProjectTheme]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
@@ -331,7 +344,6 @@ export function ProjectContextMenu({
|
||||
toast.success('Project removed', {
|
||||
description: `${project.name} has been removed from your projects list`,
|
||||
});
|
||||
setRemoveConfirmed(true);
|
||||
}, [moveProjectToTrash, project.id, project.name]);
|
||||
|
||||
const handleDialogClose = useCallback(
|
||||
@@ -340,8 +352,6 @@ export function ProjectContextMenu({
|
||||
// Close the context menu when dialog closes (whether confirmed or cancelled)
|
||||
// This prevents the context menu from reappearing after dialog interaction
|
||||
if (!isOpen) {
|
||||
// Reset confirmation state
|
||||
setRemoveConfirmed(false);
|
||||
// Always close the context menu when dialog closes
|
||||
onClose();
|
||||
}
|
||||
@@ -430,9 +440,13 @@ export function ProjectContextMenu({
|
||||
<div className="p-2">
|
||||
{/* Use Global Option */}
|
||||
<button
|
||||
type="button"
|
||||
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
||||
onPointerLeave={handlePreviewLeave}
|
||||
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleThemeSelect(USE_GLOBAL_THEME);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||
'text-sm font-medium text-left',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, LucideIcon } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn, sanitizeForTestId } from '@/lib/utils';
|
||||
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: ProjectSwitcherItemProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
|
||||
const hotkeyLabel =
|
||||
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
|
||||
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
|
||||
};
|
||||
|
||||
const IconComponent = getIconComponent();
|
||||
const hasCustomIcon = !!project.customIconPath;
|
||||
const hasCustomIcon = !!project.customIconPath && !imageError;
|
||||
|
||||
// Combine project.id with sanitized name for uniqueness and readability
|
||||
// Format: project-switcher-{id}-{sanitizedName}
|
||||
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
|
||||
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
|
||||
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
|
||||
)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<IconComponent
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
|
||||
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
|
||||
!sidebarOpen && 'flex-col gap-1'
|
||||
)}
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
onClick={() => navigate({ to: '/overview' })}
|
||||
data-testid="logo-button"
|
||||
>
|
||||
{/* Collapsed logo - only shown when sidebar is closed */}
|
||||
|
||||
@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
|
||||
|
||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||
|
||||
const {
|
||||
globalTheme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
setPreviewTheme,
|
||||
handlePreviewEnter,
|
||||
handlePreviewLeave,
|
||||
} = useProjectTheme();
|
||||
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||
useProjectTheme();
|
||||
|
||||
if (!sidebarOpen || projects.length === 0) {
|
||||
return null;
|
||||
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
|
||||
onValueChange={(value) => {
|
||||
if (currentProject) {
|
||||
setPreviewTheme(null);
|
||||
if (value !== '') {
|
||||
setTheme(value as ThemeMode);
|
||||
} else {
|
||||
setTheme(globalTheme);
|
||||
}
|
||||
// Only set project theme - don't change global theme
|
||||
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
|
||||
setProjectTheme(
|
||||
currentProject.id,
|
||||
value === '' ? null : (value as ThemeMode)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatShortcut } from '@/store/app-store';
|
||||
import { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
|
||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
function getOSAbbreviation(os: string): string {
|
||||
switch (os) {
|
||||
@@ -72,68 +72,14 @@ export function SidebarFooter({
|
||||
<div className="flex flex-col items-center py-2 px-2 gap-1">
|
||||
{/* Running Agents */}
|
||||
{!hideRunningAgents && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/running-agents' })}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
isActiveRoute('running-agents')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
]
|
||||
)}
|
||||
data-testid="running-agents-link"
|
||||
>
|
||||
<Activity
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isActiveRoute('running-agents') && 'text-brand-500'
|
||||
)}
|
||||
/>
|
||||
{runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm'
|
||||
)}
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Running Agents
|
||||
{runningAgentsCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
|
||||
{runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
onClick={() => navigate({ to: '/running-agents' })}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'relative flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
isActiveRoute('settings')
|
||||
isActiveRoute('running-agents')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground border border-brand-500/30',
|
||||
@@ -144,72 +90,115 @@ export function SidebarFooter({
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
]
|
||||
)}
|
||||
data-testid="settings-button"
|
||||
data-testid="running-agents-link"
|
||||
>
|
||||
<Settings
|
||||
<Activity
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isActiveRoute('settings') && 'text-brand-500'
|
||||
isActiveRoute('running-agents') && 'text-brand-500'
|
||||
)}
|
||||
/>
|
||||
{runningAgentsCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1 flex items-center justify-center',
|
||||
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
|
||||
'bg-brand-500 text-white shadow-sm'
|
||||
)}
|
||||
>
|
||||
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Global Settings
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
Running Agents
|
||||
{runningAgentsCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px]">
|
||||
{runningAgentsCount}
|
||||
</span>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
isActiveRoute('settings')
|
||||
? [
|
||||
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
|
||||
'text-foreground border border-brand-500/30',
|
||||
'shadow-md shadow-brand-500/10',
|
||||
]
|
||||
: [
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
]
|
||||
)}
|
||||
data-testid="settings-button"
|
||||
>
|
||||
<Settings
|
||||
className={cn('w-[18px] h-[18px]', isActiveRoute('settings') && 'text-brand-500')}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Global Settings
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
|
||||
{formatShortcut(shortcuts.settings, true)}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Documentation */}
|
||||
{!hideWiki && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleWikiClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
)}
|
||||
data-testid="documentation-button"
|
||||
>
|
||||
<BookOpen className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleFeedbackClick}
|
||||
onClick={handleWikiClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
)}
|
||||
data-testid="feedback-button"
|
||||
data-testid="documentation-button"
|
||||
>
|
||||
<MessageSquare className="w-[18px] h-[18px]" />
|
||||
<BookOpen className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Feedback
|
||||
Documentation
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleFeedbackClick}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-10 h-10 rounded-xl',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
)}
|
||||
data-testid="feedback-button"
|
||||
>
|
||||
<MessageSquare className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Feedback
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +206,15 @@ export function SidebarFooter({
|
||||
|
||||
// Expanded state
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
// Top border with gradient fade
|
||||
'border-t border-border/40',
|
||||
// Elevated background for visual separation
|
||||
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Running Agents Link */}
|
||||
{!hideRunningAgents && (
|
||||
<div className="px-3 py-0.5">
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -37,7 +37,7 @@ export function SidebarHeader({
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
navigate({ to: '/dashboard' });
|
||||
navigate({ to: '/overview' });
|
||||
}, [navigate]);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
@@ -92,78 +92,74 @@ export function SidebarHeader({
|
||||
isMac && isElectron() && 'pt-[10px]'
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group flex flex-col items-center"
|
||||
data-testid="logo-button"
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleLogoClick}
|
||||
className="group flex flex-col items-center"
|
||||
data-testid="logo-button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-collapsed"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bg-collapsed"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Go to Dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
|
||||
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Go to Dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Collapsed project icon with dropdown */}
|
||||
{currentProject && (
|
||||
<>
|
||||
<div className="w-full h-px bg-border/40 my-2" />
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
data-testid="collapsed-project-button"
|
||||
>
|
||||
{renderProjectIcon(currentProject)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{currentProject.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onContextMenu={(e) => onProjectContextMenu(currentProject, e)}
|
||||
className="p-1 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
data-testid="collapsed-project-button"
|
||||
>
|
||||
{renderProjectIcon(currentProject)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{currentProject.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import type { NavSection } from '../types';
|
||||
import type { Project } from '@/lib/electron';
|
||||
import type { SidebarStyle } from '@automaker/types';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,7 +16,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
// Map section labels to icons
|
||||
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -23,6 +27,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
|
||||
interface SidebarNavigationProps {
|
||||
currentProject: Project | null;
|
||||
sidebarOpen: boolean;
|
||||
sidebarStyle: SidebarStyle;
|
||||
navSections: NavSection[];
|
||||
isActiveRoute: (id: string) => boolean;
|
||||
navigate: (opts: NavigateOptions) => void;
|
||||
@@ -32,6 +37,7 @@ interface SidebarNavigationProps {
|
||||
export function SidebarNavigation({
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
navSections,
|
||||
isActiveRoute,
|
||||
navigate,
|
||||
@@ -39,21 +45,26 @@ export function SidebarNavigation({
|
||||
}: SidebarNavigationProps) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Track collapsed state for each collapsible section
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
||||
// Get collapsed state from store (persisted across restarts)
|
||||
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
|
||||
|
||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||
// Only set defaults for sections that don't have a persisted state
|
||||
useEffect(() => {
|
||||
setCollapsedSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
let hasNewSections = false;
|
||||
const updated = { ...collapsedNavSections };
|
||||
|
||||
navSections.forEach((section) => {
|
||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||
updated[section.label] = section.defaultCollapsed ?? false;
|
||||
hasNewSections = true;
|
||||
}
|
||||
});
|
||||
}, [navSections]);
|
||||
|
||||
if (hasNewSections) {
|
||||
setCollapsedNavSections(updated);
|
||||
}
|
||||
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
|
||||
|
||||
// Check scroll state
|
||||
const checkScrollState = useCallback(() => {
|
||||
@@ -77,30 +88,62 @@ export function SidebarNavigation({
|
||||
nav.removeEventListener('scroll', checkScrollState);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState, collapsedSections]);
|
||||
|
||||
const toggleSection = useCallback((label: string) => {
|
||||
setCollapsedSections((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
}, []);
|
||||
}, [checkScrollState, collapsedNavSections]);
|
||||
|
||||
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||
const visibleSections = navSections.filter((section) => {
|
||||
// Always show Dashboard (first section with no label)
|
||||
if (!section.label && section.items.some((item) => item.id === 'dashboard')) {
|
||||
if (!section.label && section.items.some((item) => item.id === 'overview')) {
|
||||
return true;
|
||||
}
|
||||
// Show other sections only when project is selected
|
||||
return !!currentProject;
|
||||
});
|
||||
|
||||
// Get the icon component for the current project
|
||||
const getProjectIcon = (): LucideIcon => {
|
||||
if (currentProject?.icon && currentProject.icon in LucideIcons) {
|
||||
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
|
||||
}
|
||||
return Folder;
|
||||
};
|
||||
|
||||
const ProjectIcon = getProjectIcon();
|
||||
const hasCustomIcon = !!currentProject?.customIconPath;
|
||||
|
||||
return (
|
||||
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||
// Add top padding in discord mode since there's no header
|
||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
||||
)}
|
||||
>
|
||||
{/* Project name display for classic/discord mode */}
|
||||
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-2.5 px-3 py-2">
|
||||
{hasCustomIcon ? (
|
||||
<img
|
||||
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
|
||||
alt={currentProject.name}
|
||||
className="w-5 h-5 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-px bg-border/40 mx-1 mt-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation sections */}
|
||||
{visibleSections.map((section, sectionIdx) => {
|
||||
const isCollapsed = section.label ? collapsedSections[section.label] : false;
|
||||
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
||||
const isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||
|
||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||
@@ -110,21 +153,37 @@ export function SidebarNavigation({
|
||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||
{section.label && sidebarOpen && (
|
||||
<button
|
||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
||||
onClick={() => isCollapsible && toggleNavSection(section.label!)}
|
||||
className={cn(
|
||||
'flex items-center w-full px-3 mb-1.5',
|
||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
||||
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
|
||||
'transition-all duration-200 ease-out',
|
||||
isCollapsible
|
||||
? [
|
||||
'cursor-pointer',
|
||||
'hover:bg-accent/50 hover:text-foreground',
|
||||
'border border-transparent hover:border-border/40',
|
||||
]
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!isCollapsible}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
|
||||
isCollapsible
|
||||
? 'text-muted-foreground/70 group-hover:text-foreground'
|
||||
: 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
{section.label}
|
||||
</span>
|
||||
{isCollapsible && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
||||
isCollapsed && '-rotate-90'
|
||||
'w-3 h-3 ml-auto transition-all duration-200',
|
||||
isCollapsed
|
||||
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@@ -134,27 +193,25 @@ export function SidebarNavigation({
|
||||
{/* Section icon with dropdown (collapsed sidebar) */}
|
||||
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
|
||||
<DropdownMenu>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group flex items-center justify-center w-full py-2 rounded-lg',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<SectionIcon className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{section.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'group flex items-center justify-center w-full py-2 rounded-lg',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent/50 border border-transparent hover:border-border/40',
|
||||
'transition-all duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<SectionIcon className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{section.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
|
||||
{section.items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
|
||||
@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
|
||||
}: ThemeMenuItemProps) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
className="text-xs py-1.5"
|
||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||
onPointerLeave={onPreviewLeave}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value={option.value}
|
||||
data-testid={`project-theme-${option.value}`}
|
||||
className="text-xs py-1.5"
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</div>
|
||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||
<span>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -175,12 +175,12 @@ export function useNavigation({
|
||||
}
|
||||
|
||||
const sections: NavSection[] = [
|
||||
// Dashboard - standalone at top
|
||||
// Dashboard - standalone at top (links to projects overview)
|
||||
{
|
||||
label: '',
|
||||
items: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
id: 'overview',
|
||||
label: 'Dashboard',
|
||||
icon: Home,
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Sidebar() {
|
||||
trashedProjects,
|
||||
currentProject,
|
||||
sidebarOpen,
|
||||
sidebarStyle,
|
||||
mobileSidebarHidden,
|
||||
projectHistory,
|
||||
upsertAndSetCurrentProject,
|
||||
@@ -381,17 +382,21 @@ export function Sidebar() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
|
||||
{sidebarStyle === 'unified' && (
|
||||
<SidebarHeader
|
||||
sidebarOpen={sidebarOpen}
|
||||
currentProject={currentProject}
|
||||
onNewProject={handleNewProject}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onProjectContextMenu={handleContextMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarNavigation
|
||||
currentProject={currentProject}
|
||||
sidebarOpen={sidebarOpen}
|
||||
sidebarStyle={sidebarStyle}
|
||||
navSections={navSections}
|
||||
isActiveRoute={isActiveRoute}
|
||||
navigate={navigate}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* Provider Usage Bar
|
||||
*
|
||||
* A compact usage bar that displays usage statistics for all enabled AI providers.
|
||||
* Shows a unified view with individual provider usage indicators.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
OpenAIIcon,
|
||||
CursorIcon,
|
||||
GeminiIcon,
|
||||
OpenCodeIcon,
|
||||
MiniMaxIcon,
|
||||
GlmIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { useAllProvidersUsage } from '@/hooks/queries';
|
||||
import type { UsageProviderId, ProviderUsage } from '@automaker/types';
|
||||
import { getMaxUsagePercent } from '@automaker/types';
|
||||
|
||||
// GitHub icon component
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn('inline-block', className)} fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider icon mapping
|
||||
const PROVIDER_ICONS: Record<UsageProviderId, React.FC<{ className?: string }>> = {
|
||||
claude: AnthropicIcon,
|
||||
codex: OpenAIIcon,
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
copilot: GitHubIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
};
|
||||
|
||||
// Provider dashboard URLs
|
||||
const PROVIDER_DASHBOARD_URLS: Record<UsageProviderId, string | undefined> = {
|
||||
claude: 'https://status.claude.com',
|
||||
codex: 'https://platform.openai.com/usage',
|
||||
cursor: 'https://cursor.com/settings',
|
||||
gemini: 'https://aistudio.google.com',
|
||||
copilot: 'https://github.com/settings/copilot',
|
||||
opencode: 'https://opencode.ai',
|
||||
minimax: 'https://platform.minimax.io/user-center/payment/coding-plan',
|
||||
glm: 'https://z.ai/account',
|
||||
};
|
||||
|
||||
// Helper to get status color based on percentage
|
||||
function getStatusInfo(percentage: number) {
|
||||
if (percentage >= 90) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
|
||||
if (percentage >= 75) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
|
||||
if (percentage >= 50) return { color: 'text-yellow-500', icon: AlertTriangle, bg: 'bg-yellow-500' };
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
}
|
||||
|
||||
// Progress bar component
|
||||
function ProgressBar({ percentage, colorClass }: { percentage: number; colorClass: string }) {
|
||||
return (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage card component
|
||||
function UsageCard({
|
||||
title,
|
||||
subtitle,
|
||||
percentage,
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentage: number;
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
}) {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
const safePercentage = isValidPercentage ? percentage : 0;
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-card/50 p-3 transition-opacity',
|
||||
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
|
||||
(stale || !isValidPercentage) && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
|
||||
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{isValidPercentage ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-bold',
|
||||
status.color,
|
||||
isPrimary ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-1.5 flex justify-end">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider usage panel component
|
||||
function ProviderUsagePanel({
|
||||
providerId,
|
||||
usage,
|
||||
isStale,
|
||||
}: {
|
||||
providerId: UsageProviderId;
|
||||
usage: ProviderUsage;
|
||||
isStale: boolean;
|
||||
}) {
|
||||
const ProviderIcon = PROVIDER_ICONS[providerId];
|
||||
const dashboardUrl = PROVIDER_DASHBOARD_URLS[providerId];
|
||||
|
||||
if (!usage.available) {
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{usage.providerName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center space-y-2">
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-500/80" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usage.error || 'Not available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{usage.providerName}</span>
|
||||
</div>
|
||||
{usage.plan && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-secondary rounded text-muted-foreground">
|
||||
{usage.plan.displayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usage.primary && (
|
||||
<UsageCard
|
||||
title={usage.primary.name}
|
||||
subtitle={usage.primary.windowDurationMins ? `${usage.primary.windowDurationMins}min window` : 'Usage quota'}
|
||||
percentage={usage.primary.usedPercent}
|
||||
resetText={usage.primary.resetText}
|
||||
isPrimary={true}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage.secondary && (
|
||||
<UsageCard
|
||||
title={usage.secondary.name}
|
||||
subtitle={usage.secondary.windowDurationMins ? `${usage.secondary.windowDurationMins}min window` : 'Usage quota'}
|
||||
percentage={usage.secondary.usedPercent}
|
||||
resetText={usage.secondary.resetText}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!usage.primary && !usage.secondary && (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">
|
||||
{dashboardUrl ? (
|
||||
<>
|
||||
Check{' '}
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
dashboard
|
||||
</a>{' '}
|
||||
for details
|
||||
</>
|
||||
) : (
|
||||
'No usage data available'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderUsageBar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const {
|
||||
data: allUsage,
|
||||
isLoading,
|
||||
error,
|
||||
dataUpdatedAt,
|
||||
refetch,
|
||||
} = useAllProvidersUsage(open);
|
||||
|
||||
// Calculate overall max usage percentage
|
||||
const { maxPercent, maxProviderId, availableCount } = useMemo(() => {
|
||||
if (!allUsage?.providers) {
|
||||
return { maxPercent: 0, maxProviderId: null as UsageProviderId | null, availableCount: 0 };
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let maxId: UsageProviderId | null = null;
|
||||
let count = 0;
|
||||
|
||||
for (const [id, usage] of Object.entries(allUsage.providers)) {
|
||||
if (usage?.available) {
|
||||
count++;
|
||||
const percent = getMaxUsagePercent(usage);
|
||||
if (percent > max) {
|
||||
max = percent;
|
||||
maxId = id as UsageProviderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { maxPercent: max, maxProviderId: maxId, availableCount: count };
|
||||
}, [allUsage]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isStale = !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'bg-red-500';
|
||||
if (percentage >= 75) return 'bg-orange-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
// Get the icon for the provider with highest usage
|
||||
const MaxProviderIcon = maxProviderId ? PROVIDER_ICONS[maxProviderId] : AnthropicIcon;
|
||||
const statusColor = getStatusInfo(maxPercent).color;
|
||||
|
||||
// Get list of available providers for the dropdown
|
||||
const availableProviders = useMemo(() => {
|
||||
if (!allUsage?.providers) return [];
|
||||
return Object.entries(allUsage.providers)
|
||||
.filter(([_, usage]) => usage?.available)
|
||||
.map(([id, usage]) => ({ id: id as UsageProviderId, usage: usage! }));
|
||||
}, [allUsage]);
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
{availableCount > 0 && <MaxProviderIcon className={cn('w-4 h-4', statusColor)} />}
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{availableCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
|
||||
isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercent))}
|
||||
style={{ width: `${Math.min(maxPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{availableCount > 1 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{availableCount - 1}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl max-h-[80vh] overflow-y-auto"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10 sticky top-0 z-10">
|
||||
<span className="text-sm font-semibold">Provider Usage</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isLoading && 'animate-spin')}
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{isLoading && !allUsage ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Failed to load usage</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : availableProviders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-8 h-8 text-muted-foreground/50" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No providers available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure providers in Settings to track usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
availableProviders.map(({ id, usage }) => (
|
||||
<ProviderUsagePanel
|
||||
key={id}
|
||||
providerId={id}
|
||||
usage={usage}
|
||||
isStale={isStale}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50 sticky bottom-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{availableCount} provider{availableCount !== 1 ? 's' : ''} active
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
@@ -74,12 +70,6 @@ export function ModelOverrideTrigger({
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
|
||||
if (variant === 'icon') {
|
||||
return (
|
||||
|
||||
@@ -37,16 +37,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing model overrides per phase
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Spinner, type SpinnerVariant } from '@/components/ui/spinner';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
@@ -37,9 +37,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return <Spinner size="sm" className={className} />;
|
||||
/** Button variants that have colored backgrounds requiring foreground spinner color */
|
||||
const COLORED_BACKGROUND_VARIANTS = new Set<string>(['default', 'destructive']);
|
||||
|
||||
/** Get spinner variant based on button variant - use foreground for colored backgrounds */
|
||||
function getSpinnerVariant(
|
||||
buttonVariant: VariantProps<typeof buttonVariants>['variant']
|
||||
): SpinnerVariant {
|
||||
const variant = buttonVariant ?? 'default';
|
||||
if (COLORED_BACKGROUND_VARIANTS.has(variant)) {
|
||||
return 'foreground';
|
||||
}
|
||||
// outline, secondary, ghost, link, animated-outline use standard backgrounds
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function Button({
|
||||
@@ -57,6 +67,7 @@ function Button({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
const spinnerVariant = getSpinnerVariant(variant);
|
||||
|
||||
// Special handling for animated-outline variant
|
||||
if (variant === 'animated-outline' && !asChild) {
|
||||
@@ -83,7 +94,7 @@ function Button({
|
||||
size === 'icon' && 'p-0 gap-0'
|
||||
)}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
@@ -99,7 +110,7 @@ function Button({
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{loading && <Spinner size="sm" variant={spinnerVariant} />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from 'react';
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
@@ -479,7 +479,7 @@ export function GitDiffPanel({
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={loadDiffs} className="mt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => void loadDiffs()} className="mt-2">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
@@ -550,7 +550,12 @@ export function GitDiffPanel({
|
||||
>
|
||||
Collapse All
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={loadDiffs} className="text-xs h-7">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void loadDiffs()}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/store/app-store';
|
||||
import type { KeyboardShortcuts } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
|
||||
@@ -305,54 +305,52 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
|
||||
shortcuts configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys
|
||||
in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{' '}
|
||||
keys available
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
|
||||
shortcuts configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys in
|
||||
use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{' '}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -508,196 +506,194 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="gap-2 text-xs"
|
||||
data-testid="reset-all-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
const isEditing = editingShortcut === key;
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="gap-2 text-xs"
|
||||
data-testid="reset-all-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
const isEditing = editingShortcut === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
|
||||
isEditing ? 'border-brand-500' : 'border-sidebar-border',
|
||||
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('cmdCtrl', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-cmd-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌘' : 'Ctrl'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('alt', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-alt-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌥' : 'Alt'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('shift', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-shift-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
|
||||
isEditing ? 'border-brand-500' : 'border-sidebar-border',
|
||||
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('cmdCtrl', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-cmd-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌘' : 'Ctrl'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('alt', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-alt-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌥' : 'Alt'}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('shift', !!checked, key)
|
||||
}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-shift-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
<span className="text-muted-foreground">+</span>
|
||||
<Input
|
||||
value={keyValue}
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-12 h-7 text-center font-mono text-xs uppercase',
|
||||
shortcutError && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-input-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveShortcut();
|
||||
}}
|
||||
disabled={!!shortcutError || !keyValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs font-mono rounded border',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{formatShortcut(value, true)}
|
||||
</kbd>
|
||||
{isModified && editable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetShortcut(key);
|
||||
}}
|
||||
data-testid={`reset-shortcut-${key}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-muted-foreground">+</span>
|
||||
<Input
|
||||
value={keyValue}
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-12 h-7 text-center font-mono text-xs uppercase',
|
||||
shortcutError && 'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
{isModified && !editable && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-input-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveShortcut();
|
||||
}}
|
||||
disabled={!!shortcutError || !keyValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs font-mono rounded border',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
)}
|
||||
{editable && !isModified && (
|
||||
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{formatShortcut(value, true)}
|
||||
</kbd>
|
||||
{isModified && editable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetShortcut(key);
|
||||
}}
|
||||
data-testid={`reset-shortcut-${key}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModified && !editable && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
{editable && !isModified && (
|
||||
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editingShortcut &&
|
||||
shortcutError &&
|
||||
SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
{editingShortcut &&
|
||||
shortcutError &&
|
||||
SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useState, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
X,
|
||||
Filter,
|
||||
Circle,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import type { ComponentType, ImgHTMLAttributes, SVGProps } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentModel, ModelProvider } from '@automaker/types';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
copilot: 'copilot',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||
fill: '#4ADE80',
|
||||
},
|
||||
copilot: {
|
||||
viewBox: '0 0 98 96',
|
||||
// Official GitHub Octocat logo mark
|
||||
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
|
||||
fill: '#ffffff',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||
@@ -166,8 +173,40 @@ export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
|
||||
}
|
||||
|
||||
export function GeminiIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.gemini} {...props} />;
|
||||
const GEMINI_ICON_URL = new URL('../../assets/icons/gemini-icon.svg', import.meta.url).toString();
|
||||
const GEMINI_ICON_ALT = 'Gemini';
|
||||
|
||||
type GeminiIconProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function GeminiIcon({ title, className, ...props }: GeminiIconProps) {
|
||||
const {
|
||||
role,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
'aria-hidden': ariaHidden,
|
||||
...rest
|
||||
} = props;
|
||||
const hasAccessibleLabel = Boolean(title || ariaLabel || ariaLabelledby);
|
||||
const fallbackAlt = hasAccessibleLabel ? (title ?? ariaLabel ?? GEMINI_ICON_ALT) : '';
|
||||
|
||||
return (
|
||||
<img
|
||||
src={GEMINI_ICON_URL}
|
||||
className={cn('inline-block', className)}
|
||||
role={role ?? (hasAccessibleLabel ? 'img' : 'presentation')}
|
||||
aria-hidden={ariaHidden ?? !hasAccessibleLabel}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
alt={fallbackAlt}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopilotIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.copilot} {...props} />;
|
||||
}
|
||||
|
||||
export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
@@ -396,6 +435,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
gemini: GeminiIcon,
|
||||
copilot: CopilotIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -546,6 +586,10 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (modelStr.includes('grok')) {
|
||||
return 'grok';
|
||||
}
|
||||
// GitHub Copilot models
|
||||
if (modelStr.includes('copilot')) {
|
||||
return 'copilot';
|
||||
}
|
||||
// Cursor models - canonical format includes 'cursor-' prefix
|
||||
// Also support legacy IDs for backward compatibility
|
||||
if (
|
||||
@@ -563,6 +607,7 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
if (provider === 'codex') return 'openai';
|
||||
if (provider === 'cursor') return 'cursor';
|
||||
if (provider === 'opencode') return 'opencode';
|
||||
if (provider === 'copilot') return 'copilot';
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
@@ -587,6 +632,7 @@ export function getProviderIconForModel(
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
copilot: CopilotIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
export type SpinnerVariant = 'primary' | 'foreground' | 'muted';
|
||||
|
||||
const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xs: 'h-3 w-3',
|
||||
@@ -11,9 +12,17 @@ const sizeClasses: Record<SpinnerSize, string> = {
|
||||
xl: 'h-8 w-8',
|
||||
};
|
||||
|
||||
const variantClasses: Record<SpinnerVariant, string> = {
|
||||
primary: 'text-primary',
|
||||
foreground: 'text-primary-foreground',
|
||||
muted: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
/** Size of the spinner */
|
||||
size?: SpinnerSize;
|
||||
/** Color variant - use 'foreground' when on primary backgrounds */
|
||||
variant?: SpinnerVariant;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
@@ -21,11 +30,12 @@ interface SpinnerProps {
|
||||
/**
|
||||
* Themed spinner component using the primary brand color.
|
||||
* Use this for all loading indicators throughout the app for consistency.
|
||||
* Use variant='foreground' when placing on primary-colored backgrounds.
|
||||
*/
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
export function Spinner({ size = 'md', variant = 'primary', className }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
|
||||
className={cn(sizeClasses[size], 'animate-spin', variantClasses[variant], className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { Feature, ParsedTask } from '@automaker/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TaskInfo {
|
||||
@@ -36,7 +37,7 @@ export function TaskProgressPanel({
|
||||
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Load initial tasks from feature's planSpec
|
||||
const loadInitialTasks = useCallback(async () => {
|
||||
@@ -53,26 +54,29 @@ export function TaskProgressPanel({
|
||||
}
|
||||
|
||||
const result = await api.features.get(projectPath, featureId);
|
||||
const feature: any = (result as any).feature;
|
||||
const feature = (result as { success: boolean; feature?: Feature }).feature;
|
||||
if (result.success && feature?.planSpec?.tasks) {
|
||||
const planSpec = feature.planSpec as any;
|
||||
const planTasks = planSpec.tasks;
|
||||
const planSpec = feature.planSpec;
|
||||
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
||||
const currentId = planSpec.currentTaskId;
|
||||
const completedCount = planSpec.tasksCompleted || 0;
|
||||
|
||||
// Convert planSpec tasks to TaskInfo with proper status
|
||||
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status:
|
||||
index < completedCount
|
||||
? ('completed' as const)
|
||||
: t.id === currentId
|
||||
? ('in_progress' as const)
|
||||
: ('pending' as const),
|
||||
}));
|
||||
// planTasks is guaranteed to be defined due to the if condition check
|
||||
const initialTasks: TaskInfo[] = (planTasks as ParsedTask[]).map(
|
||||
(t: ParsedTask, index: number) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
filePath: t.filePath,
|
||||
phase: t.phase,
|
||||
status:
|
||||
index < completedCount
|
||||
? ('completed' as const)
|
||||
: t.id === currentId
|
||||
? ('in_progress' as const)
|
||||
: ('pending' as const),
|
||||
})
|
||||
);
|
||||
|
||||
setTasks(initialTasks);
|
||||
setCurrentTaskId(currentId || null);
|
||||
@@ -236,7 +240,7 @@ export function TaskProgressPanel({
|
||||
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-linear-to-b from-border/80 via-border/40 to-transparent" />
|
||||
|
||||
<div className="space-y-5">
|
||||
{tasks.map((task, index) => {
|
||||
{tasks.map((task, _index) => {
|
||||
const isActive = task.status === 'in_progress';
|
||||
const isCompleted = task.status === 'completed';
|
||||
const isPending = task.status === 'pending';
|
||||
@@ -261,7 +265,7 @@ export function TaskProgressPanel({
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-3.5 w-3.5" />}
|
||||
{isActive && <Spinner size="xs" />}
|
||||
{isActive && <Spinner size="xs" variant="foreground" />}
|
||||
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ type UsageError = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Fixed refresh interval (45 seconds)
|
||||
const REFRESH_INTERVAL_SECONDS = 45;
|
||||
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
||||
|
||||
// Helper to format reset time for Codex
|
||||
function formatCodexResetTime(unixTimestamp: number): string {
|
||||
@@ -226,19 +225,7 @@ export function UsagePopover() {
|
||||
};
|
||||
|
||||
// Calculate max percentage for header button
|
||||
const claudeMaxPercentage = claudeUsage
|
||||
? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0)
|
||||
: 0;
|
||||
|
||||
const codexMaxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const maxPercentage = Math.max(claudeMaxPercentage, codexMaxPercentage);
|
||||
const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale;
|
||||
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
@@ -246,25 +233,38 @@ export function UsagePopover() {
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
// Determine which provider icon and percentage to show based on active tab
|
||||
const getTabInfo = () => {
|
||||
if (activeTab === 'claude') {
|
||||
return {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeMaxPercentage,
|
||||
isStale: isClaudeStale,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexMaxPercentage,
|
||||
isStale: isCodexStale,
|
||||
};
|
||||
};
|
||||
const codexPrimaryWindowMinutes = codexUsage?.rateLimits?.primary?.windowDurationMins ?? null;
|
||||
const codexSecondaryWindowMinutes = codexUsage?.rateLimits?.secondary?.windowDurationMins ?? null;
|
||||
const codexWindowMinutes =
|
||||
codexSecondaryWindowMinutes && codexPrimaryWindowMinutes
|
||||
? Math.min(codexPrimaryWindowMinutes, codexSecondaryWindowMinutes)
|
||||
: (codexSecondaryWindowMinutes ?? codexPrimaryWindowMinutes);
|
||||
const codexWindowLabel = codexWindowMinutes
|
||||
? getCodexWindowLabel(codexWindowMinutes).title
|
||||
: 'Window';
|
||||
const codexWindowUsage =
|
||||
codexWindowMinutes === codexSecondaryWindowMinutes
|
||||
? codexUsage?.rateLimits?.secondary?.usedPercent
|
||||
: codexUsage?.rateLimits?.primary?.usedPercent;
|
||||
|
||||
const tabInfo = getTabInfo();
|
||||
const statusColor = getStatusInfo(tabInfo.percentage).color;
|
||||
const ProviderIcon = tabInfo.icon;
|
||||
// Determine which provider icon and percentage to show based on active tab
|
||||
const indicatorInfo =
|
||||
activeTab === 'claude'
|
||||
? {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
title: `Usage (${codexWindowLabel})`,
|
||||
};
|
||||
|
||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
||||
const ProviderIcon = indicatorInfo.icon;
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
@@ -272,17 +272,18 @@ export function UsagePopover() {
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{(claudeUsage || codexUsage) && (
|
||||
<div
|
||||
title={indicatorInfo.title}
|
||||
className={cn(
|
||||
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
|
||||
tabInfo.isStale && 'opacity-60'
|
||||
indicatorInfo.isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-500',
|
||||
getProgressBarColor(tabInfo.percentage)
|
||||
getProgressBarColor(indicatorInfo.percentage)
|
||||
)}
|
||||
style={{ width: `${Math.min(tabInfo.percentage, 100)}%` }}
|
||||
style={{ width: `${Math.min(indicatorInfo.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,13 +378,6 @@ export function UsagePopover() {
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Sonnet"
|
||||
subtitle="Weekly"
|
||||
@@ -391,6 +385,13 @@ export function UsagePopover() {
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
resetText={claudeUsage.weeklyResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claudeUsage.costLimit && claudeUsage.costLimit > 0 && (
|
||||
|
||||
@@ -5,17 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Play,
|
||||
File,
|
||||
Pencil,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Terminal, CheckCircle, XCircle, Play, File, Pencil, Wrench } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
@@ -29,13 +19,6 @@ interface ToolResult {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
tool: string;
|
||||
input: string;
|
||||
result: ToolResult | null;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function AgentToolsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const api = getElectronAPI();
|
||||
|
||||
@@ -63,7 +63,6 @@ export function AgentView() {
|
||||
sendMessage,
|
||||
clearHistory,
|
||||
stopExecution,
|
||||
error: agentError,
|
||||
serverQueue,
|
||||
addToServerQueue,
|
||||
removeFromServerQueue,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||
import { useAppStore, FileTreeNode, ProjectAnalysis, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -640,14 +640,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
||||
}
|
||||
|
||||
for (const detectedFeature of detectedFeatures) {
|
||||
await api.features.create(currentProject.path, {
|
||||
const newFeature: Feature = {
|
||||
id: generateUUID(),
|
||||
category: detectedFeature.category,
|
||||
description: detectedFeature.description,
|
||||
status: 'backlog',
|
||||
// Initialize with empty steps so the object satisfies the Feature type
|
||||
steps: [],
|
||||
} as any);
|
||||
};
|
||||
await api.features.create(currentProject.path, newFeature);
|
||||
}
|
||||
|
||||
// Invalidate React Query cache to sync UI
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
useSensors,
|
||||
rectIntersection,
|
||||
pointerWithin,
|
||||
type PointerEvent as DndPointerEvent,
|
||||
type CollisionDetection,
|
||||
type Collision,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
// Custom pointer sensor that ignores drag events from within dialogs
|
||||
@@ -16,7 +17,7 @@ class DialogAwarePointerSensor extends PointerSensor {
|
||||
static activators = [
|
||||
{
|
||||
eventName: 'onPointerDown' as const,
|
||||
handler: ({ nativeEvent: event }: { nativeEvent: DndPointerEvent }) => {
|
||||
handler: ({ nativeEvent: event }: ReactPointerEvent) => {
|
||||
// Don't start drag if the event originated from inside a dialog
|
||||
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
|
||||
return false;
|
||||
@@ -29,16 +30,13 @@ class DialogAwarePointerSensor extends PointerSensor {
|
||||
import { useAppStore, Feature } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
|
||||
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
// Board-view specific imports
|
||||
import { BoardHeader } from './board-view/board-header';
|
||||
@@ -97,8 +95,6 @@ const logger = createLogger('Board');
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
maxConcurrency: legacyMaxConcurrency,
|
||||
setMaxConcurrency: legacySetMaxConcurrency,
|
||||
defaultSkipTests,
|
||||
specCreatingForProject,
|
||||
setSpecCreatingForProject,
|
||||
@@ -109,9 +105,6 @@ export function BoardView() {
|
||||
setCurrentWorktree,
|
||||
getWorktrees,
|
||||
setWorktrees,
|
||||
useWorktrees,
|
||||
enableDependencyBlocking,
|
||||
skipVerificationInAutoMode,
|
||||
planUseSelectedWorktreeBranch,
|
||||
addFeatureUseSelectedWorktreeBranch,
|
||||
isPrimaryWorktreeBranch,
|
||||
@@ -120,8 +113,6 @@ export function BoardView() {
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
currentProject: state.currentProject,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
setMaxConcurrency: state.setMaxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
specCreatingForProject: state.specCreatingForProject,
|
||||
setSpecCreatingForProject: state.setSpecCreatingForProject,
|
||||
@@ -132,9 +123,6 @@ export function BoardView() {
|
||||
setCurrentWorktree: state.setCurrentWorktree,
|
||||
getWorktrees: state.getWorktrees,
|
||||
setWorktrees: state.setWorktrees,
|
||||
useWorktrees: state.useWorktrees,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
|
||||
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
|
||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||
@@ -151,12 +139,9 @@ export function BoardView() {
|
||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
||||
const showInitScriptIndicatorByProject = useAppStore(
|
||||
(state) => state.showInitScriptIndicatorByProject
|
||||
);
|
||||
useAppStore((state) => state.showInitScriptIndicatorByProject);
|
||||
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
|
||||
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
features: hookFeatures,
|
||||
isLoading,
|
||||
@@ -187,13 +172,9 @@ export function BoardView() {
|
||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
} | null>(null);
|
||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||
null
|
||||
);
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
|
||||
// Backlog plan dialog state
|
||||
@@ -364,12 +345,12 @@ export function BoardView() {
|
||||
}, [currentProject, worktreeRefreshKey]);
|
||||
|
||||
// Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
|
||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||
const collisionDetectionStrategy = useCallback((args: Parameters<CollisionDetection>[0]) => {
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
|
||||
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
|
||||
// These need to be detected even if they are inside a column
|
||||
const specificTargetCollisions = pointerCollisions.filter((collision: any) => {
|
||||
const specificTargetCollisions = pointerCollisions.filter((collision: Collision) => {
|
||||
const id = String(collision.id);
|
||||
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
|
||||
});
|
||||
@@ -379,7 +360,7 @@ export function BoardView() {
|
||||
}
|
||||
|
||||
// Priority 2: Columns
|
||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
|
||||
COLUMNS.some((col) => col.id === collision.id)
|
||||
);
|
||||
|
||||
@@ -433,19 +414,29 @@ export function BoardView() {
|
||||
|
||||
// Get the branch for the currently selected worktree
|
||||
// Find the worktree that matches the current selection, or use main worktree
|
||||
const selectedWorktree = useMemo(() => {
|
||||
const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
|
||||
let found;
|
||||
if (currentWorktreePath === null) {
|
||||
// Primary worktree selected - find the main worktree
|
||||
return worktrees.find((w) => w.isMain);
|
||||
found = worktrees.find((w) => w.isMain);
|
||||
} else {
|
||||
// Specific worktree selected - find it by path
|
||||
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||
}
|
||||
if (!found) return undefined;
|
||||
// Ensure all required WorktreeInfo fields are present
|
||||
return {
|
||||
...found,
|
||||
isCurrent:
|
||||
found.isCurrent ??
|
||||
(currentWorktreePath !== null ? pathsEqual(found.path, currentWorktreePath) : found.isMain),
|
||||
hasWorktree: found.hasWorktree ?? true,
|
||||
};
|
||||
}, [worktrees, currentWorktreePath]);
|
||||
|
||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||
// Must be after selectedWorktree is defined
|
||||
const autoMode = useAutoMode(selectedWorktree ?? undefined);
|
||||
const autoMode = useAutoMode(selectedWorktree);
|
||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Get worktree-specific maxConcurrency from the hook
|
||||
@@ -463,6 +454,16 @@ export function BoardView() {
|
||||
const selectedWorktreeBranch =
|
||||
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||
|
||||
// Aggregate running auto tasks across all worktrees for this project
|
||||
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
|
||||
const runningAutoTasksAllWorktrees = useMemo(() => {
|
||||
if (!currentProject?.id) return [];
|
||||
const prefix = `${currentProject.id}::`;
|
||||
return Object.entries(autoModeByWorktree)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.flatMap(([, state]) => state.runningTasks ?? []);
|
||||
}, [autoModeByWorktree, currentProject?.id]);
|
||||
|
||||
// Get in-progress features for keyboard shortcuts (needed before actions hook)
|
||||
// Must be after runningAutoTasks is defined
|
||||
const inProgressFeaturesForShortcuts = useMemo(() => {
|
||||
@@ -525,8 +526,6 @@ export function BoardView() {
|
||||
handleMoveBackToInProgress,
|
||||
handleOpenFollowUp,
|
||||
handleSendFollowUp,
|
||||
handleCommitFeature,
|
||||
handleMergeFeature,
|
||||
handleCompleteFeature,
|
||||
handleUnarchiveFeature,
|
||||
handleViewOutput,
|
||||
@@ -966,28 +965,27 @@ export function BoardView() {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent(
|
||||
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
|
||||
if (event.type === 'backlog_plan_complete') {
|
||||
setIsGeneratingPlan(false);
|
||||
if (event.result && event.result.changes?.length > 0) {
|
||||
setPendingBacklogPlan(event.result);
|
||||
toast.success('Plan ready! Click to review.', {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Review',
|
||||
onClick: () => setShowPlanDialog(true),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.info('No changes generated. Try again with a different prompt.');
|
||||
}
|
||||
} else if (event.type === 'backlog_plan_error') {
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(`Plan generation failed: ${event.error}`);
|
||||
const unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
|
||||
const event = data as { type: string; result?: BacklogPlanResult; error?: string };
|
||||
if (event.type === 'backlog_plan_complete') {
|
||||
setIsGeneratingPlan(false);
|
||||
if (event.result && event.result.changes?.length > 0) {
|
||||
setPendingBacklogPlan(event.result);
|
||||
toast.success('Plan ready! Click to review.', {
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'Review',
|
||||
onClick: () => setShowPlanDialog(true),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.info('No changes generated. Try again with a different prompt.');
|
||||
}
|
||||
} else if (event.type === 'backlog_plan_error') {
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(`Plan generation failed: ${event.error}`);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -1099,10 +1097,10 @@ export function BoardView() {
|
||||
// Build columnFeaturesMap for ListView
|
||||
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
||||
const columnFeaturesMap = useMemo(() => {
|
||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||
const columns = getColumnsWithPipeline(pipelineConfig ?? null);
|
||||
const map: Record<string, typeof hookFeatures> = {};
|
||||
for (const column of columns) {
|
||||
map[column.id] = getColumnFeatures(column.id as any);
|
||||
map[column.id] = getColumnFeatures(column.id as FeatureStatusWithPipeline);
|
||||
}
|
||||
return map;
|
||||
}, [pipelineConfig, getColumnFeatures]);
|
||||
@@ -1372,7 +1370,7 @@ export function BoardView() {
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
}}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
runningFeatureIds={runningAutoTasksAllWorktrees}
|
||||
branchCardCounts={branchCardCounts}
|
||||
features={hookFeatures.map((f) => ({
|
||||
id: f.id,
|
||||
@@ -1452,14 +1450,13 @@ export function BoardView() {
|
||||
onAddFeature={() => setShowAddDialog(true)}
|
||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||
completedCount={completedFeatures.length}
|
||||
pipelineConfig={pipelineConfig}
|
||||
pipelineConfig={pipelineConfig ?? null}
|
||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
selectedFeatureIds={selectedFeatureIds}
|
||||
onToggleFeatureSelection={toggleFeatureSelection}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
viewMode={viewMode}
|
||||
isDragging={activeFeature !== null}
|
||||
onAiSuggest={() => setShowPlanDialog(true)}
|
||||
className="transition-opacity duration-200"
|
||||
@@ -1612,7 +1609,7 @@ export function BoardView() {
|
||||
open={showPipelineSettings}
|
||||
onClose={() => setShowPipelineSettings(false)}
|
||||
projectPath={currentProject.path}
|
||||
pipelineConfig={pipelineConfig}
|
||||
pipelineConfig={pipelineConfig ?? null}
|
||||
onSave={async (config) => {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -18,24 +18,22 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onShowBoardBackground}
|
||||
className={buttonClass}
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Board Background Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Board Background Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onShowBoardBackground}
|
||||
className={buttonClass}
|
||||
data-testid="board-background-button"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Board Background Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { ProviderUsageBar } from '@/components/provider-usage-bar';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
@@ -127,8 +127,8 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Provider Usage Bar - shows all available providers, only on desktop */}
|
||||
{isMounted && !isTablet && <ProviderUsageBar />}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||
import type { ReasoningEffort } from '@automaker/types';
|
||||
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||
import { getProviderFromModel } from '@/lib/utils';
|
||||
import {
|
||||
AgentTaskInfo,
|
||||
parseAgentContext,
|
||||
formatModelName,
|
||||
DEFAULT_MODEL,
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
||||
@@ -295,7 +289,8 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// Agent Info Panel for non-backlog cards
|
||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||
if (feature.status !== 'backlog' && (agentInfo || hasPlanSpecTasks)) {
|
||||
// (The backlog case was already handled above and returned early)
|
||||
if (agentInfo || hasPlanSpecTasks) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - optional callback prop typing with feature status narrowing
|
||||
import { memo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,7 +36,7 @@ interface CardActionsProps {
|
||||
export const CardActions = memo(function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
hasContext: _hasContext,
|
||||
shortcutKey,
|
||||
isSelectionMode = false,
|
||||
onEdit,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - badge component prop variations with conditional rendering
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
@@ -28,24 +28,22 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
||||
{/* Error badge */}
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>{feature.error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>{feature.error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -100,13 +98,11 @@ export const PriorityBadges = memo(function PriorityBadges({
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
@@ -138,147 +134,137 @@ export const PriorityBadges = memo(function PriorityBadges({
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1">
|
||||
{/* Priority badge */}
|
||||
{feature.priority && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||
feature.priority === 3 &&
|
||||
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
<span className="font-bold text-xs">
|
||||
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>
|
||||
{feature.priority === 1
|
||||
? 'High Priority'
|
||||
: feature.priority === 2
|
||||
? 'Medium Priority'
|
||||
: 'Low Priority'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
feature.priority === 1 &&
|
||||
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||
feature.priority === 2 &&
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||
feature.priority === 3 &&
|
||||
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
<span className="font-bold text-xs">
|
||||
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>
|
||||
{feature.priority === 1
|
||||
? 'High Priority'
|
||||
: feature.priority === 2
|
||||
? 'Medium Priority'
|
||||
: 'Low Priority'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Manual verification badge */}
|
||||
{showManualVerification && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
|
||||
)}
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Blocked badge */}
|
||||
{isBlocked && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-orange-500/20 border-orange-500/50 text-orange-500'
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
Blocked by {blockingDependencies.length} incomplete{' '}
|
||||
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
})
|
||||
.join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-orange-500/20 border-orange-500/50 text-orange-500'
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
Blocked by {blockingDependencies.length} incomplete{' '}
|
||||
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
})
|
||||
.join(', ')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Just Finished badge */}
|
||||
{isJustFinished && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Agent just finished working on this feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
|
||||
)}
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Agent just finished working on this feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Pipeline exclusion badge */}
|
||||
{hasPipelineExclusions && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
allPipelinesExcluded
|
||||
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||
)}
|
||||
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
{allPipelinesExcluded
|
||||
? 'All pipelines skipped'
|
||||
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{allPipelinesExcluded
|
||||
? 'This feature will skip all custom pipeline steps'
|
||||
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
uniformBadgeClass,
|
||||
allPipelinesExcluded
|
||||
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||
)}
|
||||
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
{allPipelinesExcluded
|
||||
? 'All pipelines skipped'
|
||||
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{allPipelinesExcluded
|
||||
? 'This feature will skip all custom pipeline steps'
|
||||
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - content section prop typing with feature data extraction
|
||||
import { memo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - header component props with optional handlers and status variants
|
||||
import { memo, useState } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - dnd-kit draggable/droppable ref combination type incompatibilities
|
||||
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - dialog state typing with feature summary extraction
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||
import {
|
||||
|
||||
@@ -78,7 +78,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight whitespace-nowrap">
|
||||
{title}
|
||||
</h3>
|
||||
{headerAction}
|
||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
||||
{count}
|
||||
|
||||
@@ -132,7 +132,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||
</div>
|
||||
);
|
||||
@@ -156,7 +156,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<span className="whitespace-nowrap truncate">{column.label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
|
||||
// The `[key: string]: unknown` in BaseFeature causes property access type errors
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - BaseFeature index signature causes property access type errors
|
||||
import { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||
@@ -149,29 +147,27 @@ const IndicatorBadges = memo(function IndicatorBadges({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{badges.map((badge) => (
|
||||
<Tooltip key={badge.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-5 h-5 rounded border',
|
||||
badge.colorClass,
|
||||
badge.bgClass,
|
||||
badge.borderClass,
|
||||
badge.animate && 'animate-pulse'
|
||||
)}
|
||||
data-testid={`list-row-badge-${badge.key}`}
|
||||
>
|
||||
<badge.icon className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||
<p>{badge.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
{badges.map((badge) => (
|
||||
<Tooltip key={badge.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-5 h-5 rounded border',
|
||||
badge.colorClass,
|
||||
badge.bgClass,
|
||||
badge.borderClass,
|
||||
badge.animate && 'animate-pulse'
|
||||
)}
|
||||
data-testid={`list-row-badge-${badge.key}`}
|
||||
>
|
||||
<badge.icon className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||
<p>{badge.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
|
||||
import { ListHeader } from './list-header';
|
||||
import { ListRow, sortFeatures } from './list-row';
|
||||
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
||||
import { getStatusLabel, getStatusOrder } from './status-badge';
|
||||
import { getStatusOrder } from './status-badge';
|
||||
import { getColumnsWithPipeline } from '../../constants';
|
||||
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @ts-nocheck
|
||||
// @ts-nocheck - feature data building with conditional fields and model type inference
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,18 +26,10 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import {
|
||||
useAppStore,
|
||||
ModelAlias,
|
||||
ThinkingLevel,
|
||||
FeatureImage,
|
||||
PlanningMode,
|
||||
Feature,
|
||||
} from '@/store/app-store';
|
||||
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
||||
import { supportsReasoningEffort } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
PlanningModeSelect,
|
||||
@@ -50,15 +41,13 @@ import {
|
||||
} from '../shared';
|
||||
import type { WorkMode } from '../shared';
|
||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
getAncestors,
|
||||
formatAncestorContextForPrompt,
|
||||
type AncestorContext,
|
||||
} from '@automaker/dependency-resolver';
|
||||
|
||||
const logger = createLogger('AddFeatureDialog');
|
||||
|
||||
/**
|
||||
* Determines the default work mode based on global settings and current worktree selection.
|
||||
*
|
||||
@@ -179,9 +168,6 @@ export function AddFeatureDialog({
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
|
||||
// Planning mode state
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -270,6 +256,13 @@ export function AddFeatureDialog({
|
||||
allFeatures,
|
||||
]);
|
||||
|
||||
// Clear requirePlanApproval when planning mode is skip or lite
|
||||
useEffect(() => {
|
||||
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||
setRequirePlanApproval(false);
|
||||
}
|
||||
}, [planningMode]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
};
|
||||
@@ -528,26 +521,24 @@ export function AddFeatureDialog({
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span>AI & Execution</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/settings', search: { view: 'defaults' } });
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
<span>Edit Defaults</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Change default model and planning settings for new features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate({ to: '/settings', search: { view: 'defaults' } });
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
<span>Edit Defaults</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Change default model and planning settings for new features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@@ -562,41 +553,13 @@ export function AddFeatureDialog({
|
||||
|
||||
<div className="grid gap-3 grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
!modelSupportsPlanningMode && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
Planning
|
||||
</Label>
|
||||
{modelSupportsPlanningMode ? (
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<PlanningModeSelect
|
||||
mode="skip"
|
||||
onModeChange={() => {}}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Planning modes are only available for Claude Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||
<PlanningModeSelect
|
||||
mode={planningMode}
|
||||
onModeChange={setPlanningMode}
|
||||
testIdPrefix="add-feature-planning"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||
@@ -620,20 +583,14 @@ export function AddFeatureDialog({
|
||||
id="add-feature-require-approval"
|
||||
checked={requirePlanApproval}
|
||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||
disabled={
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
}
|
||||
data-testid="add-feature-require-approval-checkbox"
|
||||
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||
data-testid="add-feature-planning-require-approval-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="add-feature-require-approval"
|
||||
className={cn(
|
||||
'text-xs font-normal',
|
||||
!modelSupportsPlanningMode ||
|
||||
planningMode === 'skip' ||
|
||||
planningMode === 'lite'
|
||||
planningMode === 'skip' || planningMode === 'lite'
|
||||
? 'cursor-not-allowed text-muted-foreground'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user