mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
Compare commits
81 Commits
906f471521
...
v0.14.0rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06d25b1b5 | ||
|
|
84570842d3 | ||
|
|
63cae19aec | ||
|
|
c9e721bda7 | ||
|
|
d4b7a0c57d | ||
|
|
0b6e84ec6e | ||
|
|
e9c2afcc02 | ||
|
|
88864ad6bc | ||
|
|
ebc7987988 | ||
|
|
29b3eef500 | ||
|
|
010e516b0e | ||
|
|
00e4712ae7 | ||
|
|
4b4ae04fbe | ||
|
|
04775af561 | ||
|
|
b8fa7fc579 | ||
|
|
7fb0d0f2ca | ||
|
|
f15725f28a | ||
|
|
7d7d152d4e | ||
|
|
07f777da22 | ||
|
|
b10501ea79 | ||
|
|
1a460c301a | ||
|
|
c1f480fe49 | ||
|
|
ef3f8de33b | ||
|
|
d379bf412a | ||
|
|
cf35ca8650 | ||
|
|
4f1555f196 | ||
|
|
5aace0ce0f | ||
|
|
e439d8a632 | ||
|
|
b7c6b8bfc6 | ||
|
|
a60904bd51 | ||
|
|
d7c3337330 | ||
|
|
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 | ||
|
|
6a3993385e | ||
|
|
df7024f4ea | ||
|
|
4485c49c9b | ||
|
|
f025ced035 | ||
|
|
4f07948712 | ||
|
|
076ab14a5e | ||
|
|
b1060c6a11 | ||
|
|
cec5f91a86 | ||
|
|
927ce9121d |
25
README.md
25
README.md
@@ -288,6 +288,31 @@ services:
|
|||||||
|
|
||||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||||
|
|
||||||
|
> **⚠️ Important: Linux/WSL Users**
|
||||||
|
>
|
||||||
|
> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> # Check your UID/GID
|
||||||
|
> id -u # outputs your UID (e.g., 1000)
|
||||||
|
> id -g # outputs your GID (e.g., 1000)
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Create a `.env` file in the automaker directory:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> UID=1000
|
||||||
|
> GID=1000
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Then rebuild the images:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker compose build
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Without this, files written by the container will be inaccessible to your host user.
|
||||||
|
|
||||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||||
|
|
||||||
To enable git push and GitHub CLI operations inside the container:
|
To enable git push and GitHub CLI operations inside the container:
|
||||||
|
|||||||
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.
|
|
||||||
@@ -16,7 +16,7 @@ import { createServer } from 'http';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
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';
|
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
|
||||||
|
|
||||||
const logger = createLogger('Server');
|
const logger = createLogger('Server');
|
||||||
@@ -117,15 +117,44 @@ export function isRequestLoggingEnabled(): boolean {
|
|||||||
// Width for log box content (excluding borders)
|
// Width for log box content (excluding borders)
|
||||||
const BOX_CONTENT_WIDTH = 67;
|
const BOX_CONTENT_WIDTH = 67;
|
||||||
|
|
||||||
// Check for required environment variables
|
// Check for Claude authentication (async - runs in background)
|
||||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
// 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 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 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 w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||||
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
|
||||||
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.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
|
BOX_CONTENT_WIDTH
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -138,14 +167,13 @@ if (!hasAnthropicKey) {
|
|||||||
║ ║
|
║ ║
|
||||||
║ ${w2}║
|
║ ${w2}║
|
||||||
║ ${w3}║
|
║ ${w3}║
|
||||||
║ ║
|
|
||||||
║ ${w4}║
|
║ ${w4}║
|
||||||
|
║ ${w5}║
|
||||||
|
║ ${w6}║
|
||||||
║ ║
|
║ ║
|
||||||
╚═════════════════════════════════════════════════════════════════════╝
|
╚═════════════════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
} else {
|
})();
|
||||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize security
|
// Initialize security
|
||||||
initAllowedPaths();
|
initAllowedPaths();
|
||||||
@@ -326,7 +354,10 @@ app.get('/api/health/detailed', createDetailedHandler());
|
|||||||
app.use('/api/fs', createFsRoutes(events));
|
app.use('/api/fs', createFsRoutes(events));
|
||||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
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/auto-mode', createAutoModeRoutes(autoModeService));
|
||||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||||
@@ -359,7 +390,7 @@ const server = createServer(app);
|
|||||||
// WebSocket servers using noServer mode for proper multi-path support
|
// WebSocket servers using noServer mode for proper multi-path support
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
const terminalWss = new WebSocketServer({ noServer: true });
|
const terminalWss = new WebSocketServer({ noServer: true });
|
||||||
const terminalService = getTerminalService();
|
const terminalService = getTerminalService(settingsService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate WebSocket upgrade requests
|
* Authenticate WebSocket upgrade requests
|
||||||
@@ -769,21 +800,36 @@ process.on('uncaughtException', (error: Error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown timeout (30 seconds)
|
||||||
process.on('SIGTERM', () => {
|
const SHUTDOWN_TIMEOUT_MS = 30000;
|
||||||
logger.info('SIGTERM received, shutting down...');
|
|
||||||
|
// 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();
|
terminalService.cleanup();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
|
clearTimeout(forceExitTimeout);
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
gracefulShutdown('SIGTERM');
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
logger.info('SIGINT received, shutting down...');
|
gracefulShutdown('SIGINT');
|
||||||
terminalService.cleanup();
|
|
||||||
server.close(() => {
|
|
||||||
logger.info('Server closed');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Theme Data - Re-export terminal themes from platform package
|
||||||
|
*
|
||||||
|
* This module re-exports terminal theme data for use in the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
|
||||||
|
import type { ThemeMode } from '@automaker/types';
|
||||||
|
import type { TerminalTheme } from '@automaker/platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get terminal theme colors for a given theme mode
|
||||||
|
*/
|
||||||
|
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||||
|
return getThemeColors(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all terminal themes
|
||||||
|
*/
|
||||||
|
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
|
||||||
|
return terminalThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default terminalThemeColors;
|
||||||
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
|
||||||
import {
|
import {
|
||||||
CliProvider,
|
CliProvider,
|
||||||
type CliSpawnConfig,
|
type CliSpawnConfig,
|
||||||
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
getSpawnConfig(): CliSpawnConfig {
|
getSpawnConfig(): CliSpawnConfig {
|
||||||
return {
|
return {
|
||||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
windowsStrategy: 'direct',
|
||||||
commonPaths: {
|
commonPaths: {
|
||||||
linux: [
|
linux: [
|
||||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||||
'/usr/local/bin/cursor-agent',
|
'/usr/local/bin/cursor-agent',
|
||||||
],
|
],
|
||||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||||
// Windows paths are not used - we check for WSL installation instead
|
win32: [
|
||||||
win32: [],
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'Cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor-agent.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'Cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor-agent.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'Cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'Cursor',
|
||||||
|
'cursor.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor-agent.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor-agent.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'cursor',
|
||||||
|
'resources',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'cursor.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'Programs',
|
||||||
|
'cursor',
|
||||||
|
'cursor.exe'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||||
|
'npm',
|
||||||
|
'cursor-agent.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||||
|
'npm',
|
||||||
|
'cursor.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||||
|
'.npm-global',
|
||||||
|
'bin',
|
||||||
|
'cursor-agent.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||||
|
'.npm-global',
|
||||||
|
'bin',
|
||||||
|
'cursor.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'pnpm',
|
||||||
|
'cursor-agent.cmd'
|
||||||
|
),
|
||||||
|
path.join(
|
||||||
|
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||||
|
'pnpm',
|
||||||
|
'cursor.cmd'
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
|
|||||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||||
*/
|
*/
|
||||||
protected detectCli(): CliDetectionResult {
|
protected detectCli(): CliDetectionResult {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const findInPath = (command: string): string | null => {
|
||||||
|
try {
|
||||||
|
const result = execSync(`where ${command}`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)[0];
|
||||||
|
|
||||||
|
if (result && fs.existsSync(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not in PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCursorAgentBinary = (cliPath: string) =>
|
||||||
|
cliPath.toLowerCase().includes('cursor-agent');
|
||||||
|
|
||||||
|
const supportsCursorAgentSubcommand = (cliPath: string) => {
|
||||||
|
try {
|
||||||
|
execSync(`"${cliPath}" agent --version`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: 'pipe',
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
|
||||||
|
if (pathResult) {
|
||||||
|
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
|
||||||
|
return {
|
||||||
|
cliPath: pathResult,
|
||||||
|
useWsl: false,
|
||||||
|
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.getSpawnConfig();
|
||||||
|
for (const candidate of config.commonPaths.win32 || []) {
|
||||||
|
const resolved = candidate;
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
|
||||||
|
return {
|
||||||
|
cliPath: resolved,
|
||||||
|
useWsl: false,
|
||||||
|
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslLogger = (msg: string) => logger.debug(msg);
|
||||||
|
if (isWslAvailable({ logger: wslLogger })) {
|
||||||
|
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||||
|
if (wslResult) {
|
||||||
|
logger.debug(
|
||||||
|
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
cliPath: 'wsl.exe',
|
||||||
|
useWsl: true,
|
||||||
|
wslCliPath: wslResult.wslPath,
|
||||||
|
wslDistribution: wslResult.distribution,
|
||||||
|
strategy: 'wsl',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('cursor-agent not found on Windows');
|
||||||
|
return { cliPath: null, useWsl: false, strategy: 'direct' };
|
||||||
|
}
|
||||||
|
|
||||||
// First try standard detection (PATH, common paths, WSL)
|
// First try standard detection (PATH, common paths, WSL)
|
||||||
const result = super.detectCli();
|
const result = super.detectCli();
|
||||||
if (result.cliPath) {
|
if (result.cliPath) {
|
||||||
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
// Cursor-specific: Check versions directory for any installed version
|
// Cursor-specific: Check versions directory for any installed version
|
||||||
// This handles cases where cursor-agent is installed but not in PATH
|
// This handles cases where cursor-agent is installed but not in PATH
|
||||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||||
try {
|
try {
|
||||||
const versions = fs
|
const versions = fs
|
||||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||||
@@ -521,7 +706,6 @@ export class CursorProvider extends CliProvider {
|
|||||||
|
|
||||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
const cursorPaths = [
|
const cursorPaths = [
|
||||||
'/usr/bin/cursor',
|
'/usr/bin/cursor',
|
||||||
'/usr/local/bin/cursor',
|
'/usr/local/bin/cursor',
|
||||||
@@ -550,7 +734,6 @@ export class CursorProvider extends CliProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
|
|||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||||
|
import { getAppSpecPath } from '@automaker/platform';
|
||||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||||
import type { SettingsService } from '../../../services/settings-service.js';
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||||
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import * as secureFs from '../../../lib/secure-fs.js';
|
||||||
import {
|
import {
|
||||||
buildUserPrompt,
|
buildUserPrompt,
|
||||||
isValidEnhancementMode,
|
isValidEnhancementMode,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '../../../lib/enhancement-prompts.js';
|
} from '../../../lib/enhancement-prompts.js';
|
||||||
|
import {
|
||||||
|
extractTechnologyStack,
|
||||||
|
extractXmlElements,
|
||||||
|
extractXmlSection,
|
||||||
|
unescapeXml,
|
||||||
|
} from '../../../lib/xml-extractor.js';
|
||||||
|
|
||||||
const logger = createLogger('EnhancePrompt');
|
const logger = createLogger('EnhancePrompt');
|
||||||
|
|
||||||
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
|
|||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildProjectContext(projectPath: string): Promise<string | null> {
|
||||||
|
const contextBlocks: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appSpecPath = getAppSpecPath(projectPath);
|
||||||
|
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||||
|
|
||||||
|
const projectName = extractXmlSection(specContent, 'project_name');
|
||||||
|
const overview = extractXmlSection(specContent, 'overview');
|
||||||
|
const techStack = extractTechnologyStack(specContent);
|
||||||
|
const coreSection = extractXmlSection(specContent, 'core_capabilities');
|
||||||
|
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
|
||||||
|
|
||||||
|
const summaryLines: string[] = [];
|
||||||
|
if (projectName) {
|
||||||
|
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
|
||||||
|
}
|
||||||
|
if (overview) {
|
||||||
|
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
|
||||||
|
}
|
||||||
|
if (techStack.length > 0) {
|
||||||
|
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (coreCapabilities.length > 0) {
|
||||||
|
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summaryLines.length > 0) {
|
||||||
|
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('No app_spec.txt context available for enhancement', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const featureLoader = new FeatureLoader();
|
||||||
|
const features = await featureLoader.getAll(projectPath);
|
||||||
|
const featureTitles = features
|
||||||
|
.map((feature) => feature.title || feature.name || feature.id)
|
||||||
|
.filter((title) => Boolean(title));
|
||||||
|
|
||||||
|
if (featureTitles.length > 0) {
|
||||||
|
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
|
||||||
|
contextBlocks.push(
|
||||||
|
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
|
||||||
|
featureTitles.length > 30 ? '\n- ...' : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Failed to load existing features for enhancement context', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextBlocks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contextBlocks.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the enhance request handler
|
* Create the enhance request handler
|
||||||
*
|
*
|
||||||
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
|
|||||||
|
|
||||||
// Build the user prompt with few-shot examples
|
// Build the user prompt with few-shot examples
|
||||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||||
|
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
|
||||||
|
if (projectContext) {
|
||||||
|
logger.debug('Including project context in enhancement prompt');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||||
// If so, get the provider config and resolved Claude model
|
// If so, get the provider config and resolved Claude model
|
||||||
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
|
|||||||
// The system prompt is combined with user prompt since some providers
|
// The system prompt is combined with user prompt since some providers
|
||||||
// don't have a separate system prompt concept
|
// don't have a separate system prompt concept
|
||||||
const result = await simpleQuery({
|
const result = await simpleQuery({
|
||||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||||
model: resolvedModel,
|
model: resolvedModel,
|
||||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||||
maxTurns: 1,
|
maxTurns: 1,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.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 type { EventEmitter } from '../../lib/events.js';
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
import { createListHandler } from './routes/list.js';
|
import { createListHandler } from './routes/list.js';
|
||||||
@@ -22,11 +23,16 @@ import { createImportHandler, createConflictCheckHandler } from './routes/import
|
|||||||
export function createFeaturesRoutes(
|
export function createFeaturesRoutes(
|
||||||
featureLoader: FeatureLoader,
|
featureLoader: FeatureLoader,
|
||||||
settingsService?: SettingsService,
|
settingsService?: SettingsService,
|
||||||
events?: EventEmitter
|
events?: EventEmitter,
|
||||||
|
autoModeService?: AutoModeService
|
||||||
): Router {
|
): Router {
|
||||||
const router = 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('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||||
router.post(
|
router.post(
|
||||||
'/create',
|
'/create',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
|||||||
if (events) {
|
if (events) {
|
||||||
events.emit('feature:created', {
|
events.emit('feature:created', {
|
||||||
featureId: created.id,
|
featureId: created.id,
|
||||||
featureName: created.name,
|
featureName: created.title || 'Untitled Feature',
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list endpoint - List all features for a project
|
* 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 type { Request, Response } from 'express';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
|
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||||
import { getErrorMessage, logError } from '../common.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> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath } = req.body as { projectPath: string };
|
const { projectPath } = req.body as { projectPath: string };
|
||||||
@@ -17,6 +24,26 @@ export function createListHandler(featureLoader: FeatureLoader) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const features = await featureLoader.getAll(projectPath);
|
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 });
|
res.json({ success: true, features });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'List features failed');
|
logError(error, 'List features failed');
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
|
|||||||
await secureFs.mkdir(boardDir, { recursive: true });
|
await secureFs.mkdir(boardDir, { recursive: true });
|
||||||
|
|
||||||
// Decode base64 data (remove data URL prefix if present)
|
// 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');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
// Use a fixed filename for the board background (overwrite previous)
|
// Use a fixed filename for the board background (overwrite previous)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
import { getImagesDir } from '@automaker/platform';
|
import { getImagesDir } from '@automaker/platform';
|
||||||
|
import { sanitizeFilename } from '@automaker/utils';
|
||||||
|
|
||||||
export function createSaveImageHandler() {
|
export function createSaveImageHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -31,13 +32,15 @@ export function createSaveImageHandler() {
|
|||||||
await secureFs.mkdir(imagesDir, { recursive: true });
|
await secureFs.mkdir(imagesDir, { recursive: true });
|
||||||
|
|
||||||
// Decode base64 data (remove data URL prefix if present)
|
// 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');
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
// Generate unique filename with timestamp
|
// Generate unique filename with timestamp
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const ext = path.extname(filename) || '.png';
|
const ext = path.extname(filename) || '.png';
|
||||||
const baseName = path.basename(filename, ext);
|
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
|
||||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||||
const filePath = path.join(imagesDir, uniqueFilename);
|
const filePath = path.join(imagesDir, uniqueFilename);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
|
|||||||
import { getErrorMessage, logError, logger } from '../common.js';
|
import { getErrorMessage, logError, logger } from '../common.js';
|
||||||
import { setLogLevel, LogLevel } from '@automaker/utils';
|
import { setLogLevel, LogLevel } from '@automaker/utils';
|
||||||
import { setRequestLoggingEnabled } from '../../../index.js';
|
import { setRequestLoggingEnabled } from '../../../index.js';
|
||||||
|
import { getTerminalService } from '../../../services/terminal-service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map server log level string to LogLevel enum
|
* Map server log level string to LogLevel enum
|
||||||
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get old settings to detect theme changes
|
||||||
|
const oldSettings = await settingsService.getGlobalSettings();
|
||||||
|
const oldTheme = oldSettings?.theme;
|
||||||
|
|
||||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||||
const settings = await settingsService.updateGlobalSettings(updates);
|
const settings = await settingsService.updateGlobalSettings(updates);
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
|||||||
settings.projects?.length ?? 0
|
settings.projects?.length ?? 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle theme change - regenerate terminal RC files for all projects
|
||||||
|
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
|
||||||
|
const terminalService = getTerminalService(settingsService);
|
||||||
|
const newTheme = updates.theme;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regenerate RC files for all projects with terminal config enabled
|
||||||
|
const projects = settings.projects || [];
|
||||||
|
for (const project of projects) {
|
||||||
|
try {
|
||||||
|
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||||
|
// Check if terminal config is enabled (global or project-specific)
|
||||||
|
const terminalConfigEnabled =
|
||||||
|
projectSettings.terminalConfig?.enabled !== false &&
|
||||||
|
settings.terminalConfig?.enabled === true;
|
||||||
|
|
||||||
|
if (terminalConfigEnabled) {
|
||||||
|
await terminalService.onThemeChange(project.path, newTheme);
|
||||||
|
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply server log level if it was updated
|
// Apply server log level if it was updated
|
||||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||||
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ export function createInitGitHandler() {
|
|||||||
// .git doesn't exist, continue with initialization
|
// .git doesn't exist, continue with initialization
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize git and create an initial empty commit
|
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
|
||||||
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
|
// and create an initial empty commit
|
||||||
|
await execAsync(
|
||||||
|
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
|
||||||
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@ export interface DevServerInfo {
|
|||||||
flushTimeout: NodeJS.Timeout | null;
|
flushTimeout: NodeJS.Timeout | null;
|
||||||
// Flag to indicate server is stopping (prevents output after stop)
|
// Flag to indicate server is stopping (prevents output after stop)
|
||||||
stopping: boolean;
|
stopping: boolean;
|
||||||
|
// Flag to indicate if URL has been detected from output
|
||||||
|
urlDetected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||||
@@ -103,6 +105,54 @@ class DevServerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect actual server URL from output
|
||||||
|
* Parses stdout/stderr for common URL patterns from dev servers
|
||||||
|
*/
|
||||||
|
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||||
|
// Skip if URL already detected
|
||||||
|
if (server.urlDetected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common URL patterns from various dev servers:
|
||||||
|
// - Vite: "Local: http://localhost:5173/"
|
||||||
|
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||||
|
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
||||||
|
// - Generic: Any http:// or https:// URL
|
||||||
|
const urlPatterns = [
|
||||||
|
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||||
|
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||||
|
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||||
|
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of urlPatterns) {
|
||||||
|
const match = content.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
const detectedUrl = match[1].trim();
|
||||||
|
// Validate it looks like a reasonable URL
|
||||||
|
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
||||||
|
server.url = detectedUrl;
|
||||||
|
server.urlDetected = true;
|
||||||
|
logger.info(
|
||||||
|
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit URL update event
|
||||||
|
if (this.emitter) {
|
||||||
|
this.emitter.emit('dev-server:url-detected', {
|
||||||
|
worktreePath: server.worktreePath,
|
||||||
|
url: detectedUrl,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming stdout/stderr data from dev server process
|
* Handle incoming stdout/stderr data from dev server process
|
||||||
* Buffers data for scrollback replay and schedules throttled emission
|
* Buffers data for scrollback replay and schedules throttled emission
|
||||||
@@ -115,6 +165,9 @@ class DevServerService {
|
|||||||
|
|
||||||
const content = data.toString();
|
const content = data.toString();
|
||||||
|
|
||||||
|
// Try to detect actual server URL from output
|
||||||
|
this.detectUrlFromOutput(server, content);
|
||||||
|
|
||||||
// Append to scrollback buffer for replay on reconnect
|
// Append to scrollback buffer for replay on reconnect
|
||||||
this.appendToScrollback(server, content);
|
this.appendToScrollback(server, content);
|
||||||
|
|
||||||
@@ -446,13 +499,14 @@ class DevServerService {
|
|||||||
const serverInfo: DevServerInfo = {
|
const serverInfo: DevServerInfo = {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
port,
|
port,
|
||||||
url: `http://${hostname}:${port}`,
|
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||||
process: devProcess,
|
process: devProcess,
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
scrollbackBuffer: '',
|
scrollbackBuffer: '',
|
||||||
outputBuffer: '',
|
outputBuffer: '',
|
||||||
flushTimeout: null,
|
flushTimeout: null,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
|
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture stdout with buffer management and event emission
|
// Capture stdout with buffer management and event emission
|
||||||
|
|||||||
@@ -169,9 +169,10 @@ export class EventHookService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build context for variable substitution
|
// Build context for variable substitution
|
||||||
|
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
||||||
const context: HookContext = {
|
const context: HookContext = {
|
||||||
featureId: payload.featureId,
|
featureId: payload.featureId,
|
||||||
featureName: payload.featureName,
|
featureName: featureName || payload.featureName,
|
||||||
projectPath: payload.projectPath,
|
projectPath: payload.projectPath,
|
||||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||||
error: payload.error || payload.message,
|
error: payload.error || payload.message,
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import * as path from 'path';
|
|||||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||||
import * as secureFs from '../lib/secure-fs.js';
|
import * as secureFs from '../lib/secure-fs.js';
|
||||||
import { createLogger } from '@automaker/utils';
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { SettingsService } from './settings-service.js';
|
||||||
|
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
|
||||||
|
import {
|
||||||
|
getRcFilePath,
|
||||||
|
getTerminalDir,
|
||||||
|
ensureRcFilesUpToDate,
|
||||||
|
type TerminalConfig,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
// System paths module handles shell binary checks and WSL detection
|
// System paths module handles shell binary checks and WSL detection
|
||||||
@@ -24,6 +32,27 @@ import {
|
|||||||
getShellPaths,
|
getShellPaths,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
|
||||||
|
const BASH_LOGIN_ARG = '--login';
|
||||||
|
const BASH_RCFILE_ARG = '--rcfile';
|
||||||
|
const SHELL_NAME_BASH = 'bash';
|
||||||
|
const SHELL_NAME_ZSH = 'zsh';
|
||||||
|
const SHELL_NAME_SH = 'sh';
|
||||||
|
const DEFAULT_SHOW_USER_HOST = true;
|
||||||
|
const DEFAULT_SHOW_PATH = true;
|
||||||
|
const DEFAULT_SHOW_TIME = false;
|
||||||
|
const DEFAULT_SHOW_EXIT_STATUS = false;
|
||||||
|
const DEFAULT_PATH_DEPTH = 0;
|
||||||
|
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||||
|
const DEFAULT_CUSTOM_PROMPT = true;
|
||||||
|
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
|
||||||
|
const DEFAULT_SHOW_GIT_BRANCH = true;
|
||||||
|
const DEFAULT_SHOW_GIT_STATUS = true;
|
||||||
|
const DEFAULT_CUSTOM_ALIASES = '';
|
||||||
|
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
|
||||||
|
const PROMPT_THEME_CUSTOM = 'custom';
|
||||||
|
const PROMPT_THEME_PREFIX = 'omp-';
|
||||||
|
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||||
|
|
||||||
// Maximum scrollback buffer size (characters)
|
// Maximum scrollback buffer size (characters)
|
||||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||||
|
|
||||||
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
|
|||||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||||
|
|
||||||
|
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
|
||||||
|
const sanitizedArgs: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (arg === BASH_LOGIN_ARG) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === BASH_RCFILE_ARG) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sanitizedArgs.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
|
||||||
|
return sanitizedArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathStyle(
|
||||||
|
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||||
|
): TerminalConfig['pathStyle'] {
|
||||||
|
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||||
|
return pathStyle;
|
||||||
|
}
|
||||||
|
return DEFAULT_PATH_STYLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||||
|
const depth =
|
||||||
|
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||||
|
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShellBasename(shellPath: string): string {
|
||||||
|
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||||
|
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShellArgsForPath(shellPath: string): string[] {
|
||||||
|
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
|
||||||
|
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (shellName === SHELL_NAME_SH) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [BASH_LOGIN_ARG];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
|
||||||
|
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
|
||||||
|
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEffectiveTerminalConfig(
|
||||||
|
globalTerminalConfig: TerminalConfig | undefined,
|
||||||
|
projectTerminalConfig: Partial<TerminalConfig> | undefined
|
||||||
|
): TerminalConfig {
|
||||||
|
const mergedEnvVars = {
|
||||||
|
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||||
|
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
|
||||||
|
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
|
||||||
|
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
|
||||||
|
showGitBranch:
|
||||||
|
projectTerminalConfig?.showGitBranch ??
|
||||||
|
globalTerminalConfig?.showGitBranch ??
|
||||||
|
DEFAULT_SHOW_GIT_BRANCH,
|
||||||
|
showGitStatus:
|
||||||
|
projectTerminalConfig?.showGitStatus ??
|
||||||
|
globalTerminalConfig?.showGitStatus ??
|
||||||
|
DEFAULT_SHOW_GIT_STATUS,
|
||||||
|
showUserHost:
|
||||||
|
projectTerminalConfig?.showUserHost ??
|
||||||
|
globalTerminalConfig?.showUserHost ??
|
||||||
|
DEFAULT_SHOW_USER_HOST,
|
||||||
|
showPath:
|
||||||
|
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
|
||||||
|
pathStyle: normalizePathStyle(
|
||||||
|
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
|
||||||
|
),
|
||||||
|
pathDepth: normalizePathDepth(
|
||||||
|
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
|
||||||
|
),
|
||||||
|
showTime:
|
||||||
|
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
|
||||||
|
showExitStatus:
|
||||||
|
projectTerminalConfig?.showExitStatus ??
|
||||||
|
globalTerminalConfig?.showExitStatus ??
|
||||||
|
DEFAULT_SHOW_EXIT_STATUS,
|
||||||
|
customAliases:
|
||||||
|
projectTerminalConfig?.customAliases ??
|
||||||
|
globalTerminalConfig?.customAliases ??
|
||||||
|
DEFAULT_CUSTOM_ALIASES,
|
||||||
|
customEnvVars: mergedEnvVars,
|
||||||
|
rcFileVersion: globalTerminalConfig?.rcFileVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
pty: pty.IPty;
|
pty: pty.IPty;
|
||||||
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
|
|||||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||||
|
private settingsService: SettingsService | null = null;
|
||||||
|
|
||||||
|
constructor(settingsService?: SettingsService) {
|
||||||
|
super();
|
||||||
|
this.settingsService = settingsService || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kill a PTY process with platform-specific handling.
|
* Kill a PTY process with platform-specific handling.
|
||||||
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
|
|||||||
const platform = os.platform();
|
const platform = os.platform();
|
||||||
const shellPaths = getShellPaths();
|
const shellPaths = getShellPaths();
|
||||||
|
|
||||||
// Helper to get basename handling both path separators
|
|
||||||
const getBasename = (shellPath: string): string => {
|
|
||||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
|
||||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get shell args based on shell name
|
|
||||||
const getShellArgs = (shell: string): string[] => {
|
|
||||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
|
||||||
// PowerShell and cmd don't need --login
|
|
||||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// sh doesn't support --login in all implementations
|
|
||||||
if (shellName === 'sh') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// bash, zsh, and other POSIX shells support --login
|
|
||||||
return ['--login'];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if running in WSL - prefer user's shell or bash with --login
|
// Check if running in WSL - prefer user's shell or bash with --login
|
||||||
if (platform === 'linux' && this.isWSL()) {
|
if (platform === 'linux' && this.isWSL()) {
|
||||||
const userShell = process.env.SHELL;
|
const userShell = process.env.SHELL;
|
||||||
if (userShell) {
|
if (userShell) {
|
||||||
// Try to find userShell in allowed paths
|
// Try to find userShell in allowed paths
|
||||||
for (const allowedShell of shellPaths) {
|
for (const allowedShell of shellPaths) {
|
||||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
if (
|
||||||
|
allowedShell === userShell ||
|
||||||
|
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (systemPathExists(allowedShell)) {
|
if (systemPathExists(allowedShell)) {
|
||||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Path not allowed, continue searching
|
// Path not allowed, continue searching
|
||||||
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
for (const shell of shellPaths) {
|
for (const shell of shellPaths) {
|
||||||
try {
|
try {
|
||||||
if (systemPathExists(shell)) {
|
if (systemPathExists(shell)) {
|
||||||
return { shell, args: getShellArgs(shell) };
|
return { shell, args: getShellArgsForPath(shell) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Path not allowed, continue
|
// Path not allowed, continue
|
||||||
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
|
|||||||
if (userShell && platform !== 'win32') {
|
if (userShell && platform !== 'win32') {
|
||||||
// Try to find userShell in allowed paths
|
// Try to find userShell in allowed paths
|
||||||
for (const allowedShell of shellPaths) {
|
for (const allowedShell of shellPaths) {
|
||||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
if (
|
||||||
|
allowedShell === userShell ||
|
||||||
|
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (systemPathExists(allowedShell)) {
|
if (systemPathExists(allowedShell)) {
|
||||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Path not allowed, continue searching
|
// Path not allowed, continue searching
|
||||||
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
for (const shell of shellPaths) {
|
for (const shell of shellPaths) {
|
||||||
try {
|
try {
|
||||||
if (systemPathExists(shell)) {
|
if (systemPathExists(shell)) {
|
||||||
return { shell, args: getShellArgs(shell) };
|
return { shell, args: getShellArgsForPath(shell) };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Path not allowed or doesn't exist, continue to next
|
// Path not allowed or doesn't exist, continue to next
|
||||||
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
|
|||||||
|
|
||||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
|
||||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
|
||||||
const shell = options.shell || detectedShell;
|
const shell = options.shell || detectedShell;
|
||||||
|
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
|
||||||
|
|
||||||
// Validate and resolve working directory
|
// Validate and resolve working directory
|
||||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||||
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal config injection (custom prompts, themes)
|
||||||
|
const terminalConfigEnv: Record<string, string> = {};
|
||||||
|
if (this.settingsService) {
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
|
||||||
|
);
|
||||||
|
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||||
|
const projectSettings = options.cwd
|
||||||
|
? await this.settingsService.getProjectSettings(options.cwd)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const globalTerminalConfig = globalSettings?.terminalConfig;
|
||||||
|
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||||
|
const effectiveConfig = buildEffectiveTerminalConfig(
|
||||||
|
globalTerminalConfig,
|
||||||
|
projectTerminalConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (effectiveConfig.enabled && globalTerminalConfig) {
|
||||||
|
const currentTheme = globalSettings?.theme || 'dark';
|
||||||
|
const themeColors = getTerminalThemeColors(currentTheme);
|
||||||
|
const allThemes = getAllTerminalThemes();
|
||||||
|
const promptTheme =
|
||||||
|
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
|
||||||
|
const ompThemeName = resolveOmpThemeName(promptTheme);
|
||||||
|
|
||||||
|
// Ensure RC files are up to date
|
||||||
|
await ensureRcFilesUpToDate(
|
||||||
|
options.cwd || cwd,
|
||||||
|
currentTheme,
|
||||||
|
effectiveConfig,
|
||||||
|
themeColors,
|
||||||
|
allThemes
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set shell-specific env vars
|
||||||
|
const shellName = getShellBasename(shell).toLowerCase();
|
||||||
|
if (ompThemeName && effectiveConfig.customPrompt) {
|
||||||
|
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellName.includes(SHELL_NAME_BASH)) {
|
||||||
|
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
|
||||||
|
terminalConfigEnv.BASH_ENV = bashRcFilePath;
|
||||||
|
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||||
|
? 'true'
|
||||||
|
: 'false';
|
||||||
|
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||||
|
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
|
||||||
|
} else if (shellName.includes(SHELL_NAME_ZSH)) {
|
||||||
|
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
|
||||||
|
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||||
|
? 'true'
|
||||||
|
: 'false';
|
||||||
|
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||||
|
} else if (shellName === SHELL_NAME_SH) {
|
||||||
|
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
|
||||||
|
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||||
|
? 'true'
|
||||||
|
: 'false';
|
||||||
|
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom env vars from config
|
||||||
|
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...cleanEnv,
|
...cleanEnv,
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
|
|||||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||||
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
||||||
...options.env,
|
...options.env,
|
||||||
|
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||||
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
|
|||||||
return () => this.exitCallbacks.delete(callback);
|
return () => this.exitCallbacks.delete(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle theme change - regenerate RC files with new theme colors
|
||||||
|
*/
|
||||||
|
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
|
||||||
|
if (!this.settingsService) {
|
||||||
|
logger.warn('[onThemeChange] SettingsService not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||||
|
const terminalConfig = globalSettings?.terminalConfig;
|
||||||
|
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
|
||||||
|
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||||
|
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
|
||||||
|
|
||||||
|
if (effectiveConfig.enabled && terminalConfig) {
|
||||||
|
const themeColors = getTerminalThemeColors(
|
||||||
|
newTheme as import('@automaker/types').ThemeMode
|
||||||
|
);
|
||||||
|
const allThemes = getAllTerminalThemes();
|
||||||
|
|
||||||
|
// Regenerate RC files with new theme
|
||||||
|
await ensureRcFilesUpToDate(
|
||||||
|
projectPath,
|
||||||
|
newTheme as import('@automaker/types').ThemeMode,
|
||||||
|
effectiveConfig,
|
||||||
|
themeColors,
|
||||||
|
allThemes
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up all sessions
|
* Clean up all sessions
|
||||||
*/
|
*/
|
||||||
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let terminalService: TerminalService | null = null;
|
let terminalService: TerminalService | null = null;
|
||||||
|
|
||||||
export function getTerminalService(): TerminalService {
|
export function getTerminalService(settingsService?: SettingsService): TerminalService {
|
||||||
if (!terminalService) {
|
if (!terminalService) {
|
||||||
terminalService = new TerminalService();
|
terminalService = new TerminalService(settingsService);
|
||||||
}
|
}
|
||||||
return terminalService;
|
return terminalService;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export interface TestRepo {
|
|||||||
export async function createTestGitRepo(): Promise<TestRepo> {
|
export async function createTestGitRepo(): Promise<TestRepo> {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
|
||||||
|
|
||||||
// Initialize git repo
|
// Initialize git repo with 'main' as the default branch (matching GitHub's standard)
|
||||||
await execAsync('git init', { cwd: tmpDir });
|
await execAsync('git init --initial-branch=main', { cwd: tmpDir });
|
||||||
|
|
||||||
// Use environment variables instead of git config to avoid affecting user's git config
|
// Use environment variables instead of git config to avoid affecting user's git config
|
||||||
// These env vars override git config without modifying it
|
// These env vars override git config without modifying it
|
||||||
@@ -38,9 +38,6 @@ export async function createTestGitRepo(): Promise<TestRepo> {
|
|||||||
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
|
||||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
|
||||||
|
|
||||||
// Create main branch explicitly
|
|
||||||
await execAsync('git branch -M main', { cwd: tmpDir });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: tmpDir,
|
path: tmpDir,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ describe('worktree create route - repositories without commits', () => {
|
|||||||
|
|
||||||
async function initRepoWithoutCommit() {
|
async function initRepoWithoutCommit() {
|
||||||
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
|
||||||
await execAsync('git init', { cwd: repoPath });
|
// Initialize with 'main' as the default branch (matching GitHub's standard)
|
||||||
|
await execAsync('git init --initial-branch=main', { cwd: repoPath });
|
||||||
// Don't set git config - use environment variables in commit operations instead
|
// Don't set git config - use environment variables in commit operations instead
|
||||||
// to avoid affecting user's git config
|
// to avoid affecting user's git config
|
||||||
// Intentionally skip creating an initial commit
|
// Intentionally skip creating an initial commit
|
||||||
|
|||||||
@@ -315,4 +315,531 @@ describe('auto-mode-service.ts', () => {
|
|||||||
expect(duration).toBeLessThan(40);
|
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 { describe, it, expect } from 'vitest';
|
||||||
|
import type { ParsedTask } from '@automaker/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test the task parsing logic by reimplementing the parsing functions
|
* Test the task parsing logic by reimplementing the parsing functions
|
||||||
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
* 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 {
|
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||||
// Match pattern: - [ ] T###: Description | File: path
|
// Match pattern: - [ ] T###: Description | File: path
|
||||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||||
@@ -342,4 +335,236 @@ Some other text
|
|||||||
expect(fullModeOutput).toContain('[SPEC_GENERATED]');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ import net from 'net';
|
|||||||
|
|
||||||
describe('dev-server-service.ts', () => {
|
describe('dev-server-service.ts', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
let originalHostname: string | undefined;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
// Store and set HOSTNAME for consistent test behavior
|
||||||
|
originalHostname = process.env.HOSTNAME;
|
||||||
|
process.env.HOSTNAME = 'localhost';
|
||||||
|
|
||||||
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
|
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
|
||||||
await fs.mkdir(testDir, { recursive: true });
|
await fs.mkdir(testDir, { recursive: true });
|
||||||
|
|
||||||
@@ -56,6 +61,13 @@ describe('dev-server-service.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
// Restore original HOSTNAME
|
||||||
|
if (originalHostname === undefined) {
|
||||||
|
delete process.env.HOSTNAME;
|
||||||
|
} else {
|
||||||
|
process.env.HOSTNAME = originalHostname;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.rm(testDir, { recursive: true, force: true });
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -368,6 +380,148 @@ describe('dev-server-service.ts', () => {
|
|||||||
expect(service.listDevServers().result.servers).toHaveLength(0);
|
expect(service.listDevServers().result.servers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('URL detection from output', () => {
|
||||||
|
it('should detect Vite format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate Vite output
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
|
||||||
|
|
||||||
|
// Give it a moment to process
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Next.js format URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate Next.js output
|
||||||
|
mockProcess.stdout.emit(
|
||||||
|
'data',
|
||||||
|
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect generic localhost URL', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate generic output with URL
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:8080');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep initial URL if no URL detected in output', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
const result = await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate output without URL
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
// Should keep the initial allocated URL
|
||||||
|
expect(serverInfo?.url).toBe(result.result?.url);
|
||||||
|
expect(serverInfo?.urlDetected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect HTTPS URLs', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// Simulate HTTPS dev server
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe('https://localhost:3443');
|
||||||
|
expect(serverInfo?.urlDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only detect URL once (not update after first detection)', async () => {
|
||||||
|
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockProcess = createMockProcess();
|
||||||
|
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||||
|
|
||||||
|
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||||
|
const service = getDevServerService();
|
||||||
|
|
||||||
|
await service.startDevServer(testDir, testDir);
|
||||||
|
|
||||||
|
// First URL
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const firstUrl = service.getServerInfo(testDir)?.url;
|
||||||
|
|
||||||
|
// Try to emit another URL
|
||||||
|
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Should keep the first detected URL
|
||||||
|
const serverInfo = service.getServerInfo(testDir);
|
||||||
|
expect(serverInfo?.url).toBe(firstUrl);
|
||||||
|
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to create a mock child process
|
// Helper to create a mock child process
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ const eslintConfig = defineConfig([
|
|||||||
require: 'readonly',
|
require: 'readonly',
|
||||||
__dirname: 'readonly',
|
__dirname: 'readonly',
|
||||||
__filename: 'readonly',
|
__filename: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts', '**/*.tsx'],
|
files: ['**/*.ts', '**/*.tsx'],
|
||||||
@@ -45,6 +50,8 @@ const eslintConfig = defineConfig([
|
|||||||
confirm: 'readonly',
|
confirm: 'readonly',
|
||||||
getComputedStyle: 'readonly',
|
getComputedStyle: 'readonly',
|
||||||
requestAnimationFrame: 'readonly',
|
requestAnimationFrame: 'readonly',
|
||||||
|
cancelAnimationFrame: 'readonly',
|
||||||
|
alert: 'readonly',
|
||||||
// DOM Element Types
|
// DOM Element Types
|
||||||
HTMLElement: 'readonly',
|
HTMLElement: 'readonly',
|
||||||
HTMLInputElement: 'readonly',
|
HTMLInputElement: 'readonly',
|
||||||
@@ -56,6 +63,8 @@ const eslintConfig = defineConfig([
|
|||||||
HTMLParagraphElement: 'readonly',
|
HTMLParagraphElement: 'readonly',
|
||||||
HTMLImageElement: 'readonly',
|
HTMLImageElement: 'readonly',
|
||||||
Element: 'readonly',
|
Element: 'readonly',
|
||||||
|
SVGElement: 'readonly',
|
||||||
|
SVGSVGElement: 'readonly',
|
||||||
// Event Types
|
// Event Types
|
||||||
Event: 'readonly',
|
Event: 'readonly',
|
||||||
KeyboardEvent: 'readonly',
|
KeyboardEvent: 'readonly',
|
||||||
@@ -64,14 +73,24 @@ const eslintConfig = defineConfig([
|
|||||||
CustomEvent: 'readonly',
|
CustomEvent: 'readonly',
|
||||||
ClipboardEvent: 'readonly',
|
ClipboardEvent: 'readonly',
|
||||||
WheelEvent: 'readonly',
|
WheelEvent: 'readonly',
|
||||||
|
MouseEvent: 'readonly',
|
||||||
|
UIEvent: 'readonly',
|
||||||
|
MediaQueryListEvent: 'readonly',
|
||||||
DataTransfer: 'readonly',
|
DataTransfer: 'readonly',
|
||||||
// Web APIs
|
// Web APIs
|
||||||
ResizeObserver: 'readonly',
|
ResizeObserver: 'readonly',
|
||||||
AbortSignal: 'readonly',
|
AbortSignal: 'readonly',
|
||||||
|
AbortController: 'readonly',
|
||||||
|
IntersectionObserver: 'readonly',
|
||||||
Audio: 'readonly',
|
Audio: 'readonly',
|
||||||
|
HTMLAudioElement: 'readonly',
|
||||||
ScrollBehavior: 'readonly',
|
ScrollBehavior: 'readonly',
|
||||||
URL: 'readonly',
|
URL: 'readonly',
|
||||||
URLSearchParams: 'readonly',
|
URLSearchParams: 'readonly',
|
||||||
|
XMLHttpRequest: 'readonly',
|
||||||
|
Response: 'readonly',
|
||||||
|
RequestInit: 'readonly',
|
||||||
|
RequestCache: 'readonly',
|
||||||
// Timers
|
// Timers
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
setInterval: 'readonly',
|
setInterval: 'readonly',
|
||||||
@@ -90,6 +109,8 @@ const eslintConfig = defineConfig([
|
|||||||
Electron: 'readonly',
|
Electron: 'readonly',
|
||||||
// Console
|
// Console
|
||||||
console: 'readonly',
|
console: 'readonly',
|
||||||
|
// Vite defines
|
||||||
|
__APP_VERSION__: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -99,6 +120,13 @@ const eslintConfig = defineConfig([
|
|||||||
...ts.configs.recommended.rules,
|
...ts.configs.recommended.rules,
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/ban-ts-comment': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'ts-nocheck': 'allow-with-description',
|
||||||
|
minimumDescriptionLength: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
|
|||||||
@@ -170,6 +170,10 @@
|
|||||||
"from": "server-bundle/node_modules",
|
"from": "server-bundle/node_modules",
|
||||||
"to": "server/node_modules"
|
"to": "server/node_modules"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "server-bundle/libs",
|
||||||
|
"to": "server/libs"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "server-bundle/package.json",
|
"from": "server-bundle/package.json",
|
||||||
"to": "server/package.json"
|
"to": "server/package.json"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async function killProcessOnPort(port) {
|
|||||||
try {
|
try {
|
||||||
await execAsync(`kill -9 ${pid}`);
|
await execAsync(`kill -9 ${pid}`);
|
||||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Process might have already exited
|
// Process might have already exited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ async function killProcessOnPort(port) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// No process on port, which is fine
|
// No process on port, which is fine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
|
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname, resolve } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -112,6 +112,29 @@ execSync('npm install --omit=dev', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 6b: Replace symlinks for local packages with real copies
|
||||||
|
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
|
||||||
|
console.log('🔗 Replacing symlinks with real directory copies...');
|
||||||
|
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
|
||||||
|
for (const pkgName of LOCAL_PACKAGES) {
|
||||||
|
const pkgDir = pkgName.replace('@automaker/', '');
|
||||||
|
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
|
||||||
|
try {
|
||||||
|
// lstatSync does not follow symlinks, allowing us to check for broken ones
|
||||||
|
if (lstatSync(nmPkgPath).isSymbolicLink()) {
|
||||||
|
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
|
||||||
|
rmSync(nmPkgPath);
|
||||||
|
cpSync(realPath, nmPkgPath, { recursive: true });
|
||||||
|
console.log(` ✓ Replaced symlink: ${pkgName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 7: Rebuild native modules for current architecture
|
// Step 7: Rebuild native modules for current architecture
|
||||||
// This is critical for modules like node-pty that have native bindings
|
// This is critical for modules like node-pty that have native bindings
|
||||||
console.log('🔨 Rebuilding native modules for current architecture...');
|
console.log('🔨 Rebuilding native modules for current architecture...');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSettingsSync } from './hooks/use-settings-sync';
|
|||||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||||
import { useAppStore } from './store/app-store';
|
import { useAppStore } from './store/app-store';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/theme-imports';
|
import './styles/theme-imports';
|
||||||
import './styles/font-imports';
|
import './styles/font-imports';
|
||||||
@@ -75,9 +76,9 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TooltipProvider delayDuration={300}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
{showSplash && !disableSplashScreen && <SplashScreen onComplete={handleSplashComplete} />}
|
||||||
</>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export function CodexUsagePopover() {
|
|||||||
// Use React Query for data fetching with automatic polling
|
// Use React Query for data fetching with automatic polling
|
||||||
const {
|
const {
|
||||||
data: codexUsage,
|
data: codexUsage,
|
||||||
isLoading,
|
|
||||||
isFetching,
|
isFetching,
|
||||||
error: queryError,
|
error: queryError,
|
||||||
dataUpdatedAt,
|
dataUpdatedAt,
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ interface FileBrowserDialogProps {
|
|||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RECENT_FOLDERS = 5;
|
|
||||||
|
|
||||||
export function FileBrowserDialog({
|
export function FileBrowserDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
// Use platform-specific path separator
|
// Use platform-specific path separator
|
||||||
const pathSep =
|
const pathSep =
|
||||||
typeof window !== 'undefined' && (window as any).electronAPI
|
typeof window !== 'undefined' && window.electronAPI
|
||||||
? navigator.platform.indexOf('Win') !== -1
|
? navigator.platform.indexOf('Win') !== -1
|
||||||
? '\\'
|
? '\\'
|
||||||
: '/'
|
: '/'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { IconPicker } from './icon-picker';
|
import { IconPicker } from './icon-picker';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface EditProjectDialogProps {
|
interface EditProjectDialogProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -25,9 +26,9 @@ interface EditProjectDialogProps {
|
|||||||
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
|
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
|
||||||
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
|
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
|
||||||
const [name, setName] = useState(project.name);
|
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>(
|
const [customIconPath, setCustomIconPath] = useState<string | null>(
|
||||||
(project as any).customIconPath || null
|
project.customIconPath || null
|
||||||
);
|
);
|
||||||
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -36,10 +37,10 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
|||||||
if (name.trim() !== project.name) {
|
if (name.trim() !== project.name) {
|
||||||
setProjectName(project.id, name.trim());
|
setProjectName(project.id, name.trim());
|
||||||
}
|
}
|
||||||
if (icon !== (project as any).icon) {
|
if (icon !== project.icon) {
|
||||||
setProjectIcon(project.id, icon);
|
setProjectIcon(project.id, icon);
|
||||||
}
|
}
|
||||||
if (customIconPath !== (project as any).customIconPath) {
|
if (customIconPath !== project.customIconPath) {
|
||||||
setProjectCustomIcon(project.id, customIconPath);
|
setProjectCustomIcon(project.id, customIconPath);
|
||||||
}
|
}
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
|||||||
// Validate file type
|
// Validate file type
|
||||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
|
toast.error(
|
||||||
|
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size (max 2MB for icons)
|
// Validate file size (max 5MB for icons - allows animated GIFs)
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
|||||||
file.type,
|
file.type,
|
||||||
project.path
|
project.path
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
setCustomIconPath(result.path);
|
setCustomIconPath(result.path);
|
||||||
// Clear the Lucide icon when custom icon is set
|
// Clear the Lucide icon when custom icon is set
|
||||||
setIcon(null);
|
setIcon(null);
|
||||||
|
toast.success('Icon uploaded successfully');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to upload icon');
|
||||||
}
|
}
|
||||||
setIsUploadingIcon(false);
|
setIsUploadingIcon(false);
|
||||||
};
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
toast.error('Failed to read file');
|
||||||
|
setIsUploadingIcon(false);
|
||||||
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
} catch {
|
} catch {
|
||||||
|
toast.error('Failed to upload icon');
|
||||||
setIsUploadingIcon(false);
|
setIsUploadingIcon(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
|
|||||||
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
PNG, JPG, GIF or WebP. Max 2MB.
|
PNG, JPG, GIF or WebP. Max 5MB.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
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 { useNavigate } from '@tanstack/react-router';
|
||||||
import { useNotificationsStore } from '@/store/notifications-store';
|
import { useNotificationsStore } from '@/store/notifications-store';
|
||||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ interface ThemeButtonProps {
|
|||||||
/** Handler for pointer leave events (used to clear preview) */
|
/** Handler for pointer leave events (used to clear preview) */
|
||||||
onPointerLeave: (e: React.PointerEvent) => void;
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
/** Handler for click events (used to select theme) */
|
/** 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;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onPointerEnter={onPointerEnter}
|
onPointerEnter={onPointerEnter}
|
||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
|
|||||||
isSelected={selectedTheme === option.value}
|
isSelected={selectedTheme === option.value}
|
||||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||||
onPointerLeave={onPreviewLeave}
|
onPointerLeave={onPreviewLeave}
|
||||||
onClick={() => onSelect(option.value)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(option.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,13 +197,11 @@ export function ProjectContextMenu({
|
|||||||
const {
|
const {
|
||||||
moveProjectToTrash,
|
moveProjectToTrash,
|
||||||
theme: globalTheme,
|
theme: globalTheme,
|
||||||
setTheme,
|
|
||||||
setProjectTheme,
|
setProjectTheme,
|
||||||
setPreviewTheme,
|
setPreviewTheme,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
|
||||||
const [removeConfirmed, setRemoveConfirmed] = useState(false);
|
|
||||||
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
const themeSubmenuRef = useRef<HTMLDivElement>(null);
|
||||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -317,13 +319,24 @@ export function ProjectContextMenu({
|
|||||||
|
|
||||||
const handleThemeSelect = useCallback(
|
const handleThemeSelect = useCallback(
|
||||||
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
|
(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);
|
setPreviewTheme(null);
|
||||||
const isUsingGlobal = value === USE_GLOBAL_THEME;
|
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);
|
setProjectTheme(project.id, isUsingGlobal ? null : value);
|
||||||
setShowThemeSubmenu(false);
|
|
||||||
},
|
},
|
||||||
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
|
[onClose, project.id, setPreviewTheme, setProjectTheme]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirmRemove = useCallback(() => {
|
const handleConfirmRemove = useCallback(() => {
|
||||||
@@ -331,7 +344,6 @@ export function ProjectContextMenu({
|
|||||||
toast.success('Project removed', {
|
toast.success('Project removed', {
|
||||||
description: `${project.name} has been removed from your projects list`,
|
description: `${project.name} has been removed from your projects list`,
|
||||||
});
|
});
|
||||||
setRemoveConfirmed(true);
|
|
||||||
}, [moveProjectToTrash, project.id, project.name]);
|
}, [moveProjectToTrash, project.id, project.name]);
|
||||||
|
|
||||||
const handleDialogClose = useCallback(
|
const handleDialogClose = useCallback(
|
||||||
@@ -340,8 +352,6 @@ export function ProjectContextMenu({
|
|||||||
// Close the context menu when dialog closes (whether confirmed or cancelled)
|
// Close the context menu when dialog closes (whether confirmed or cancelled)
|
||||||
// This prevents the context menu from reappearing after dialog interaction
|
// This prevents the context menu from reappearing after dialog interaction
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
// Reset confirmation state
|
|
||||||
setRemoveConfirmed(false);
|
|
||||||
// Always close the context menu when dialog closes
|
// Always close the context menu when dialog closes
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -430,9 +440,13 @@ export function ProjectContextMenu({
|
|||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{/* Use Global Option */}
|
{/* Use Global Option */}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
onPointerEnter={() => handlePreviewEnter(globalTheme)}
|
||||||
onPointerLeave={handlePreviewLeave}
|
onPointerLeave={handlePreviewLeave}
|
||||||
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleThemeSelect(USE_GLOBAL_THEME);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
|
||||||
'text-sm font-medium text-left',
|
'text-sm font-medium text-left',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Folder, LucideIcon } from 'lucide-react';
|
import { Folder, LucideIcon } from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import { cn, sanitizeForTestId } from '@/lib/utils';
|
import { cn, sanitizeForTestId } from '@/lib/utils';
|
||||||
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
|
|||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: ProjectSwitcherItemProps) {
|
}: ProjectSwitcherItemProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
|
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
|
||||||
const hotkeyLabel =
|
const hotkeyLabel =
|
||||||
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
|
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
|
||||||
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const IconComponent = getIconComponent();
|
const IconComponent = getIconComponent();
|
||||||
const hasCustomIcon = !!project.customIconPath;
|
const hasCustomIcon = !!project.customIconPath && !imageError;
|
||||||
|
|
||||||
// Combine project.id with sanitized name for uniqueness and readability
|
// Combine project.id with sanitized name for uniqueness and readability
|
||||||
// Format: project-switcher-{id}-{sanitizedName}
|
// Format: project-switcher-{id}-{sanitizedName}
|
||||||
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
|
|||||||
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
|
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
|
||||||
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
|
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
|
||||||
)}
|
)}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, isMac } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||||
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
import { ProjectSwitcherItem } from './components/project-switcher-item';
|
||||||
@@ -11,9 +11,12 @@ import { NotificationBell } from './components/notification-bell';
|
|||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||||
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
|
||||||
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
|
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
|
import {
|
||||||
|
MACOS_ELECTRON_TOP_PADDING_CLASS,
|
||||||
|
SIDEBAR_FEATURE_FLAGS,
|
||||||
|
} from '@/components/layout/sidebar/constants';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
||||||
@@ -279,7 +282,12 @@ export function ProjectSwitcher() {
|
|||||||
data-testid="project-switcher"
|
data-testid="project-switcher"
|
||||||
>
|
>
|
||||||
{/* Automaker Logo and Version */}
|
{/* Automaker Logo and Version */}
|
||||||
<div className="flex flex-col items-center pt-3 pb-2 px-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center pb-2 px-2',
|
||||||
|
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/dashboard' })}
|
onClick={() => navigate({ to: '/dashboard' })}
|
||||||
className="group flex flex-col items-center gap-0.5"
|
className="group flex flex-col items-center gap-0.5"
|
||||||
|
|||||||
@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
|
|||||||
|
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
|
|
||||||
const {
|
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||||
globalTheme,
|
useProjectTheme();
|
||||||
setTheme,
|
|
||||||
setProjectTheme,
|
|
||||||
setPreviewTheme,
|
|
||||||
handlePreviewEnter,
|
|
||||||
handlePreviewLeave,
|
|
||||||
} = useProjectTheme();
|
|
||||||
|
|
||||||
if (!sidebarOpen || projects.length === 0) {
|
if (!sidebarOpen || projects.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
setPreviewTheme(null);
|
setPreviewTheme(null);
|
||||||
if (value !== '') {
|
// Only set project theme - don't change global theme
|
||||||
setTheme(value as ThemeMode);
|
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
|
||||||
} else {
|
|
||||||
setTheme(globalTheme);
|
|
||||||
}
|
|
||||||
setProjectTheme(
|
setProjectTheme(
|
||||||
currentProject.id,
|
currentProject.id,
|
||||||
value === '' ? null : (value as ThemeMode)
|
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 { Activity, Settings, BookOpen, MessageSquare, ExternalLink } from 'lucide-react';
|
||||||
import { useOSDetection } from '@/hooks/use-os-detection';
|
import { useOSDetection } from '@/hooks/use-os-detection';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
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 {
|
function getOSAbbreviation(os: string): string {
|
||||||
switch (os) {
|
switch (os) {
|
||||||
@@ -72,7 +72,6 @@ export function SidebarFooter({
|
|||||||
<div className="flex flex-col items-center py-2 px-2 gap-1">
|
<div className="flex flex-col items-center py-2 px-2 gap-1">
|
||||||
{/* Running Agents */}
|
{/* Running Agents */}
|
||||||
{!hideRunningAgents && (
|
{!hideRunningAgents && (
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -121,11 +120,9 @@ export function SidebarFooter({
|
|||||||
)}
|
)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -147,10 +144,7 @@ export function SidebarFooter({
|
|||||||
data-testid="settings-button"
|
data-testid="settings-button"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
className={cn(
|
className={cn('w-[18px] h-[18px]', isActiveRoute('settings') && 'text-brand-500')}
|
||||||
'w-[18px] h-[18px]',
|
|
||||||
isActiveRoute('settings') && 'text-brand-500'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -161,11 +155,9 @@ export function SidebarFooter({
|
|||||||
</span>
|
</span>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{/* Documentation */}
|
{/* Documentation */}
|
||||||
{!hideWiki && (
|
{!hideWiki && (
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -185,11 +177,9 @@ export function SidebarFooter({
|
|||||||
Documentation
|
Documentation
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback */}
|
{/* Feedback */}
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -209,7 +199,6 @@ export function SidebarFooter({
|
|||||||
Feedback
|
Feedback
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react';
|
|||||||
import { cn, isMac } from '@/lib/utils';
|
import { cn, isMac } from '@/lib/utils';
|
||||||
import { formatShortcut } from '@/store/app-store';
|
import { formatShortcut } from '@/store/app-store';
|
||||||
import { isElectron, type Project } from '@/lib/electron';
|
import { isElectron, type Project } from '@/lib/electron';
|
||||||
|
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
|
||||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
@@ -89,10 +90,9 @@ export function SidebarHeader({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
|
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
|
||||||
isMac && isElectron() && 'pt-[10px]'
|
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -139,14 +139,12 @@ export function SidebarHeader({
|
|||||||
Go to Dashboard
|
Go to Dashboard
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{/* Collapsed project icon with dropdown */}
|
{/* Collapsed project icon with dropdown */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<>
|
<>
|
||||||
<div className="w-full h-px bg-border/40 my-2" />
|
<div className="w-full h-px bg-border/40 my-2" />
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -163,7 +161,6 @@ export function SidebarHeader({
|
|||||||
{currentProject.name}
|
{currentProject.name}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="start"
|
align="start"
|
||||||
side="right"
|
side="right"
|
||||||
@@ -244,7 +241,7 @@ export function SidebarHeader({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
|
||||||
isMac && isElectron() && 'pt-[10px]'
|
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header with logo and project dropdown */}
|
{/* Header with logo and project dropdown */}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import type { NavigateOptions } from '@tanstack/react-router';
|
import type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import * as LucideIcons from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { cn, isMac } from '@/lib/utils';
|
||||||
|
import { isElectron } from '@/lib/electron';
|
||||||
|
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
|
||||||
import { formatShortcut, useAppStore } from '@/store/app-store';
|
import { formatShortcut, useAppStore } from '@/store/app-store';
|
||||||
|
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||||
import type { NavSection } from '../types';
|
import type { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import type { SidebarStyle } from '@automaker/types';
|
import type { SidebarStyle } from '@automaker/types';
|
||||||
@@ -13,7 +18,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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
|
// Map section labels to icons
|
||||||
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
const sectionIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@@ -97,15 +102,52 @@ export function SidebarNavigation({
|
|||||||
return !!currentProject;
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
|
||||||
// Add top padding in discord mode since there's no header
|
// Add top padding in discord mode since there's no header
|
||||||
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
|
// Extra padding for macOS Electron to avoid traffic light overlap
|
||||||
|
sidebarStyle === 'discord'
|
||||||
|
? isMac && isElectron()
|
||||||
|
? MACOS_ELECTRON_TOP_PADDING_CLASS
|
||||||
|
: '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 */}
|
{/* Navigation sections */}
|
||||||
{visibleSections.map((section, sectionIdx) => {
|
{visibleSections.map((section, sectionIdx) => {
|
||||||
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
|
||||||
@@ -158,7 +200,6 @@ export function SidebarNavigation({
|
|||||||
{/* Section icon with dropdown (collapsed sidebar) */}
|
{/* Section icon with dropdown (collapsed sidebar) */}
|
||||||
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
|
{section.label && !sidebarOpen && SectionIcon && section.collapsible && isCollapsed && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -178,7 +219,6 @@ export function SidebarNavigation({
|
|||||||
{section.label}
|
{section.label}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
|
<DropdownMenuContent side="right" align="start" sideOffset={8} className="w-48">
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const ItemIcon = item.icon;
|
const ItemIcon = item.icon;
|
||||||
|
|||||||
@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
|
|||||||
}: ThemeMenuItemProps) {
|
}: ThemeMenuItemProps) {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
onPointerEnter={() => onPreviewEnter(option.value)}
|
|
||||||
onPointerLeave={onPreviewLeave}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem
|
<DropdownMenuRadioItem
|
||||||
value={option.value}
|
value={option.value}
|
||||||
data-testid={`project-theme-${option.value}`}
|
data-testid={`project-theme-${option.value}`}
|
||||||
className="text-xs py-1.5"
|
className="text-xs py-1.5"
|
||||||
|
onPointerEnter={() => onPreviewEnter(option.value)}
|
||||||
|
onPointerLeave={onPreviewLeave}
|
||||||
>
|
>
|
||||||
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { darkThemes, lightThemes } from '@/config/theme-options';
|
import { darkThemes, lightThemes } from '@/config/theme-options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
|
||||||
|
* This padding is applied conditionally when running on macOS in Electron.
|
||||||
|
*/
|
||||||
|
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared constants for theme submenu positioning and layout.
|
* Shared constants for theme submenu positioning and layout.
|
||||||
* Used across project-context-menu and project-selector-with-options components
|
* Used across project-context-menu and project-selector-with-options components
|
||||||
|
|||||||
@@ -405,9 +405,28 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll indicator - shows there's more content below */}
|
{/* Scroll indicator - shows there's more content below */}
|
||||||
{canScrollDown && sidebarOpen && (
|
{canScrollDown && (
|
||||||
<div className="flex justify-center py-1 border-t border-border/30">
|
<div
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
className={cn(
|
||||||
|
'relative flex justify-center py-2 border-t border-border/30',
|
||||||
|
'bg-gradient-to-t from-background via-background/95 to-transparent',
|
||||||
|
'-mt-8 pt-8',
|
||||||
|
'pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto flex flex-col items-center gap-0.5">
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 text-brand-500/70 animate-bounce',
|
||||||
|
sidebarOpen ? 'block' : 'w-3 h-3'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{sidebarOpen && (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||||
|
Scroll
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Settings2 } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
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 { useAppStore } from '@/store/app-store';
|
||||||
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
@@ -74,12 +70,6 @@ export function ModelOverrideTrigger({
|
|||||||
lg: 'h-10 w-10',
|
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
|
// For icon variant, wrap PhaseModelSelector and hide text/chevron with CSS
|
||||||
if (variant === 'icon') {
|
if (variant === 'icon') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -37,16 +37,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
|||||||
return entry;
|
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
|
* Hook for managing model overrides per phase
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
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">
|
<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" />
|
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||||
<span className="text-sm">{error}</span>
|
<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" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
@@ -550,7 +550,12 @@ export function GitDiffPanel({
|
|||||||
>
|
>
|
||||||
Collapse All
|
Collapse All
|
||||||
</Button>
|
</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" />
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import type { KeyboardShortcuts } from '@/store/app-store';
|
import type { KeyboardShortcuts } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
|
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
|
||||||
@@ -305,7 +305,6 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
|
||||||
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
|
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||||
@@ -341,8 +340,8 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
|||||||
shortcuts configured
|
shortcuts configured
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys
|
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys in
|
||||||
in use
|
use
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="text-foreground">
|
<strong className="text-foreground">
|
||||||
@@ -352,7 +351,6 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +506,6 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
|
||||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||||
{editable && (
|
{editable && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -698,6 +695,5 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
Filter,
|
Filter,
|
||||||
Circle,
|
Circle,
|
||||||
Play,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@@ -116,9 +116,8 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
|||||||
},
|
},
|
||||||
copilot: {
|
copilot: {
|
||||||
viewBox: '0 0 98 96',
|
viewBox: '0 0 98 96',
|
||||||
// Official GitHub Octocat logo mark
|
// Official GitHub Octocat logo mark (theme-aware via currentColor)
|
||||||
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',
|
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',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
import type { Feature, ParsedTask } from '@automaker/types';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
interface TaskInfo {
|
interface TaskInfo {
|
||||||
@@ -36,7 +37,7 @@ export function TaskProgressPanel({
|
|||||||
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
const [tasks, setTasks] = useState<TaskInfo[]>([]);
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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
|
// Load initial tasks from feature's planSpec
|
||||||
const loadInitialTasks = useCallback(async () => {
|
const loadInitialTasks = useCallback(async () => {
|
||||||
@@ -53,15 +54,17 @@ export function TaskProgressPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.features.get(projectPath, featureId);
|
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) {
|
if (result.success && feature?.planSpec?.tasks) {
|
||||||
const planSpec = feature.planSpec as any;
|
const planSpec = feature.planSpec;
|
||||||
const planTasks = planSpec.tasks;
|
const planTasks = planSpec.tasks; // Already guarded by the if condition above
|
||||||
const currentId = planSpec.currentTaskId;
|
const currentId = planSpec.currentTaskId;
|
||||||
const completedCount = planSpec.tasksCompleted || 0;
|
const completedCount = planSpec.tasksCompleted || 0;
|
||||||
|
|
||||||
// Convert planSpec tasks to TaskInfo with proper status
|
// Convert planSpec tasks to TaskInfo with proper status
|
||||||
const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({
|
// 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,
|
id: t.id,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
filePath: t.filePath,
|
filePath: t.filePath,
|
||||||
@@ -72,7 +75,8 @@ export function TaskProgressPanel({
|
|||||||
: t.id === currentId
|
: t.id === currentId
|
||||||
? ('in_progress' as const)
|
? ('in_progress' as const)
|
||||||
: ('pending' as const),
|
: ('pending' as const),
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
setTasks(initialTasks);
|
setTasks(initialTasks);
|
||||||
setCurrentTaskId(currentId || null);
|
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="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">
|
<div className="space-y-5">
|
||||||
{tasks.map((task, index) => {
|
{tasks.map((task, _index) => {
|
||||||
const isActive = task.status === 'in_progress';
|
const isActive = task.status === 'in_progress';
|
||||||
const isCompleted = task.status === 'completed';
|
const isCompleted = task.status === 'completed';
|
||||||
const isPending = task.status === 'pending';
|
const isPending = task.status === 'pending';
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ type UsageError = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fixed refresh interval (45 seconds)
|
|
||||||
const REFRESH_INTERVAL_SECONDS = 45;
|
|
||||||
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
||||||
|
|
||||||
// Helper to format reset time for Codex
|
// Helper to format reset time for Codex
|
||||||
@@ -229,15 +227,6 @@ export function UsagePopover() {
|
|||||||
// Calculate max percentage for header button
|
// Calculate max percentage for header button
|
||||||
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
||||||
|
|
||||||
const codexMaxPercentage = codexUsage?.rateLimits
|
|
||||||
? Math.max(
|
|
||||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
|
||||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const isStale = activeTab === 'claude' ? isClaudeStale : isCodexStale;
|
|
||||||
|
|
||||||
const getProgressBarColor = (percentage: number) => {
|
const getProgressBarColor = (percentage: number) => {
|
||||||
if (percentage >= 80) return 'bg-red-500';
|
if (percentage >= 80) return 'bg-red-500';
|
||||||
if (percentage >= 50) return 'bg-yellow-500';
|
if (percentage >= 50) return 'bg-yellow-500';
|
||||||
|
|||||||
@@ -5,17 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import { Terminal, CheckCircle, XCircle, Play, File, Pencil, Wrench } from 'lucide-react';
|
||||||
FileText,
|
|
||||||
FolderOpen,
|
|
||||||
Terminal,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Play,
|
|
||||||
File,
|
|
||||||
Pencil,
|
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -29,13 +19,6 @@ interface ToolResult {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolExecution {
|
|
||||||
tool: string;
|
|
||||||
input: string;
|
|
||||||
result: ToolResult | null;
|
|
||||||
isRunning: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentToolsView() {
|
export function AgentToolsView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export function AgentView() {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
stopExecution,
|
stopExecution,
|
||||||
error: agentError,
|
|
||||||
serverQueue,
|
serverQueue,
|
||||||
addToServerQueue,
|
addToServerQueue,
|
||||||
removeFromServerQueue,
|
removeFromServerQueue,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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 { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -640,14 +640,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const detectedFeature of detectedFeatures) {
|
for (const detectedFeature of detectedFeatures) {
|
||||||
await api.features.create(currentProject.path, {
|
const newFeature: Feature = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
category: detectedFeature.category,
|
category: detectedFeature.category,
|
||||||
description: detectedFeature.description,
|
description: detectedFeature.description,
|
||||||
status: 'backlog',
|
status: 'backlog',
|
||||||
// Initialize with empty steps so the object satisfies the Feature type
|
|
||||||
steps: [],
|
steps: [],
|
||||||
} as any);
|
};
|
||||||
|
await api.features.create(currentProject.path, newFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate React Query cache to sync UI
|
// Invalidate React Query cache to sync UI
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-nocheck
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
rectIntersection,
|
rectIntersection,
|
||||||
pointerWithin,
|
pointerWithin,
|
||||||
type PointerEvent as DndPointerEvent,
|
type CollisionDetection,
|
||||||
|
type Collision,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
|
|
||||||
// Custom pointer sensor that ignores drag events from within dialogs
|
// Custom pointer sensor that ignores drag events from within dialogs
|
||||||
@@ -16,7 +17,7 @@ class DialogAwarePointerSensor extends PointerSensor {
|
|||||||
static activators = [
|
static activators = [
|
||||||
{
|
{
|
||||||
eventName: 'onPointerDown' as const,
|
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
|
// Don't start drag if the event originated from inside a dialog
|
||||||
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
|
if ((event.target as Element)?.closest?.('[role="dialog"]')) {
|
||||||
return false;
|
return false;
|
||||||
@@ -29,16 +30,13 @@ class DialogAwarePointerSensor extends PointerSensor {
|
|||||||
import { useAppStore, Feature } from '@/store/app-store';
|
import { useAppStore, Feature } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
|
|
||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
|
||||||
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { BoardHeader } from './board-view/board-header';
|
||||||
@@ -97,8 +95,6 @@ const logger = createLogger('Board');
|
|||||||
export function BoardView() {
|
export function BoardView() {
|
||||||
const {
|
const {
|
||||||
currentProject,
|
currentProject,
|
||||||
maxConcurrency: legacyMaxConcurrency,
|
|
||||||
setMaxConcurrency: legacySetMaxConcurrency,
|
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
@@ -109,9 +105,6 @@ export function BoardView() {
|
|||||||
setCurrentWorktree,
|
setCurrentWorktree,
|
||||||
getWorktrees,
|
getWorktrees,
|
||||||
setWorktrees,
|
setWorktrees,
|
||||||
useWorktrees,
|
|
||||||
enableDependencyBlocking,
|
|
||||||
skipVerificationInAutoMode,
|
|
||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
addFeatureUseSelectedWorktreeBranch,
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
@@ -120,8 +113,6 @@ export function BoardView() {
|
|||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
maxConcurrency: state.maxConcurrency,
|
|
||||||
setMaxConcurrency: state.setMaxConcurrency,
|
|
||||||
defaultSkipTests: state.defaultSkipTests,
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
specCreatingForProject: state.specCreatingForProject,
|
specCreatingForProject: state.specCreatingForProject,
|
||||||
setSpecCreatingForProject: state.setSpecCreatingForProject,
|
setSpecCreatingForProject: state.setSpecCreatingForProject,
|
||||||
@@ -132,9 +123,6 @@ export function BoardView() {
|
|||||||
setCurrentWorktree: state.setCurrentWorktree,
|
setCurrentWorktree: state.setCurrentWorktree,
|
||||||
getWorktrees: state.getWorktrees,
|
getWorktrees: state.getWorktrees,
|
||||||
setWorktrees: state.setWorktrees,
|
setWorktrees: state.setWorktrees,
|
||||||
useWorktrees: state.useWorktrees,
|
|
||||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
|
||||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
|
||||||
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
|
||||||
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
|
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
|
||||||
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||||
@@ -151,12 +139,9 @@ export function BoardView() {
|
|||||||
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
|
||||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||||
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
|
||||||
const showInitScriptIndicatorByProject = useAppStore(
|
useAppStore((state) => state.showInitScriptIndicatorByProject);
|
||||||
(state) => state.showInitScriptIndicatorByProject
|
|
||||||
);
|
|
||||||
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
|
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
|
||||||
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
|
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
const {
|
const {
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -187,13 +172,9 @@ export function BoardView() {
|
|||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||||
path: string;
|
null
|
||||||
branch: string;
|
);
|
||||||
isMain: boolean;
|
|
||||||
hasChanges?: boolean;
|
|
||||||
changedFilesCount?: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||||
|
|
||||||
// Backlog plan dialog state
|
// Backlog plan dialog state
|
||||||
@@ -364,12 +345,12 @@ export function BoardView() {
|
|||||||
}, [currentProject, worktreeRefreshKey]);
|
}, [currentProject, worktreeRefreshKey]);
|
||||||
|
|
||||||
// Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
|
// 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);
|
const pointerCollisions = pointerWithin(args);
|
||||||
|
|
||||||
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
|
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
|
||||||
// These need to be detected even if they are inside a column
|
// 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);
|
const id = String(collision.id);
|
||||||
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
|
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
|
||||||
});
|
});
|
||||||
@@ -379,7 +360,7 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Columns
|
// Priority 2: Columns
|
||||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
|
||||||
COLUMNS.some((col) => col.id === collision.id)
|
COLUMNS.some((col) => col.id === collision.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -433,19 +414,86 @@ export function BoardView() {
|
|||||||
|
|
||||||
// Get the branch for the currently selected worktree
|
// Get the branch for the currently selected worktree
|
||||||
// Find the worktree that matches the current selection, or use main 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) {
|
if (currentWorktreePath === null) {
|
||||||
// Primary worktree selected - find the main worktree
|
// Primary worktree selected - find the main worktree
|
||||||
return worktrees.find((w) => w.isMain);
|
found = worktrees.find((w) => w.isMain);
|
||||||
} else {
|
} else {
|
||||||
// Specific worktree selected - find it by path
|
// 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]);
|
}, [worktrees, currentWorktreePath]);
|
||||||
|
|
||||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||||
// Must be after selectedWorktree is defined
|
// Must be after selectedWorktree is defined
|
||||||
const autoMode = useAutoMode(selectedWorktree ?? undefined);
|
const autoMode = useAutoMode(selectedWorktree);
|
||||||
|
|
||||||
|
const refreshBoardState = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
const projectPath = currentProject.path;
|
||||||
|
const beforeFeatures = (
|
||||||
|
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||||
|
)?.length;
|
||||||
|
const beforeWorktrees = (
|
||||||
|
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||||
|
| { worktrees?: unknown[] }
|
||||||
|
| undefined
|
||||||
|
)?.worktrees?.length;
|
||||||
|
const beforeRunningAgents = (
|
||||||
|
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||||
|
)?.count;
|
||||||
|
const beforeAutoModeRunning = autoMode.isRunning;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
|
||||||
|
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
|
||||||
|
autoMode.refreshStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const afterFeatures = (
|
||||||
|
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||||
|
)?.length;
|
||||||
|
const afterWorktrees = (
|
||||||
|
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||||
|
| { worktrees?: unknown[] }
|
||||||
|
| undefined
|
||||||
|
)?.worktrees?.length;
|
||||||
|
const afterRunningAgents = (
|
||||||
|
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||||
|
)?.count;
|
||||||
|
const afterAutoModeRunning = autoMode.isRunning;
|
||||||
|
|
||||||
|
if (
|
||||||
|
beforeFeatures !== afterFeatures ||
|
||||||
|
beforeWorktrees !== afterWorktrees ||
|
||||||
|
beforeRunningAgents !== afterRunningAgents ||
|
||||||
|
beforeAutoModeRunning !== afterAutoModeRunning
|
||||||
|
) {
|
||||||
|
logger.info('[Board] Refresh detected state mismatch', {
|
||||||
|
features: { before: beforeFeatures, after: afterFeatures },
|
||||||
|
worktrees: { before: beforeWorktrees, after: afterWorktrees },
|
||||||
|
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
|
||||||
|
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Board] Failed to refresh board state:', error);
|
||||||
|
toast.error('Failed to refresh board state');
|
||||||
|
}
|
||||||
|
}, [autoMode, currentProject, queryClient]);
|
||||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||||
const runningAutoTasks = autoMode.runningTasks;
|
const runningAutoTasks = autoMode.runningTasks;
|
||||||
// Get worktree-specific maxConcurrency from the hook
|
// Get worktree-specific maxConcurrency from the hook
|
||||||
@@ -535,8 +583,6 @@ export function BoardView() {
|
|||||||
handleMoveBackToInProgress,
|
handleMoveBackToInProgress,
|
||||||
handleOpenFollowUp,
|
handleOpenFollowUp,
|
||||||
handleSendFollowUp,
|
handleSendFollowUp,
|
||||||
handleCommitFeature,
|
|
||||||
handleMergeFeature,
|
|
||||||
handleCompleteFeature,
|
handleCompleteFeature,
|
||||||
handleUnarchiveFeature,
|
handleUnarchiveFeature,
|
||||||
handleViewOutput,
|
handleViewOutput,
|
||||||
@@ -976,8 +1022,8 @@ export function BoardView() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.backlogPlan) return;
|
if (!api?.backlogPlan) return;
|
||||||
|
|
||||||
const unsubscribe = api.backlogPlan.onEvent(
|
const unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
|
||||||
(event: { type: string; result?: BacklogPlanResult; error?: string }) => {
|
const event = data as { type: string; result?: BacklogPlanResult; error?: string };
|
||||||
if (event.type === 'backlog_plan_complete') {
|
if (event.type === 'backlog_plan_complete') {
|
||||||
setIsGeneratingPlan(false);
|
setIsGeneratingPlan(false);
|
||||||
if (event.result && event.result.changes?.length > 0) {
|
if (event.result && event.result.changes?.length > 0) {
|
||||||
@@ -996,8 +1042,7 @@ export function BoardView() {
|
|||||||
setIsGeneratingPlan(false);
|
setIsGeneratingPlan(false);
|
||||||
toast.error(`Plan generation failed: ${event.error}`);
|
toast.error(`Plan generation failed: ${event.error}`);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1109,10 +1154,10 @@ export function BoardView() {
|
|||||||
// Build columnFeaturesMap for ListView
|
// Build columnFeaturesMap for ListView
|
||||||
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
||||||
const columnFeaturesMap = useMemo(() => {
|
const columnFeaturesMap = useMemo(() => {
|
||||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
const columns = getColumnsWithPipeline(pipelineConfig ?? null);
|
||||||
const map: Record<string, typeof hookFeatures> = {};
|
const map: Record<string, typeof hookFeatures> = {};
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
map[column.id] = getColumnFeatures(column.id as any);
|
map[column.id] = getColumnFeatures(column.id as FeatureStatusWithPipeline);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [pipelineConfig, getColumnFeatures]);
|
}, [pipelineConfig, getColumnFeatures]);
|
||||||
@@ -1287,8 +1332,10 @@ export function BoardView() {
|
|||||||
maxConcurrency={maxConcurrency}
|
maxConcurrency={maxConcurrency}
|
||||||
runningAgentsCount={runningAutoTasks.length}
|
runningAgentsCount={runningAutoTasks.length}
|
||||||
onConcurrencyChange={(newMaxConcurrency) => {
|
onConcurrencyChange={(newMaxConcurrency) => {
|
||||||
if (currentProject && selectedWorktree) {
|
if (currentProject) {
|
||||||
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
|
// If selectedWorktree is undefined or it's the main worktree, branchName will be null.
|
||||||
|
// Otherwise, use the branch name.
|
||||||
|
const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null;
|
||||||
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
|
||||||
|
|
||||||
// Persist to server settings so capacity checks use the correct value
|
// Persist to server settings so capacity checks use the correct value
|
||||||
@@ -1331,6 +1378,7 @@ export function BoardView() {
|
|||||||
isCreatingSpec={isCreatingSpec}
|
isCreatingSpec={isCreatingSpec}
|
||||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||||
|
onRefreshBoard={refreshBoardState}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
@@ -1462,14 +1510,13 @@ export function BoardView() {
|
|||||||
onAddFeature={() => setShowAddDialog(true)}
|
onAddFeature={() => setShowAddDialog(true)}
|
||||||
onShowCompletedModal={() => setShowCompletedModal(true)}
|
onShowCompletedModal={() => setShowCompletedModal(true)}
|
||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
pipelineConfig={pipelineConfig}
|
pipelineConfig={pipelineConfig ?? null}
|
||||||
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectionTarget={selectionTarget}
|
selectionTarget={selectionTarget}
|
||||||
selectedFeatureIds={selectedFeatureIds}
|
selectedFeatureIds={selectedFeatureIds}
|
||||||
onToggleFeatureSelection={toggleFeatureSelection}
|
onToggleFeatureSelection={toggleFeatureSelection}
|
||||||
onToggleSelectionMode={toggleSelectionMode}
|
onToggleSelectionMode={toggleSelectionMode}
|
||||||
viewMode={viewMode}
|
|
||||||
isDragging={activeFeature !== null}
|
isDragging={activeFeature !== null}
|
||||||
onAiSuggest={() => setShowPlanDialog(true)}
|
onAiSuggest={() => setShowPlanDialog(true)}
|
||||||
className="transition-opacity duration-200"
|
className="transition-opacity duration-200"
|
||||||
@@ -1622,7 +1669,7 @@ export function BoardView() {
|
|||||||
open={showPipelineSettings}
|
open={showPipelineSettings}
|
||||||
onClose={() => setShowPipelineSettings(false)}
|
onClose={() => setShowPipelineSettings(false)}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
pipelineConfig={pipelineConfig}
|
pipelineConfig={pipelineConfig ?? null}
|
||||||
onSave={async (config) => {
|
onSave={async (config) => {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.pipeline.saveConfig(currentProject.path, config);
|
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 { ImageIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -36,6 +35,5 @@ export function BoardControls({ isMounted, onShowBoardBackground }: BoardControl
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
|
||||||
import { UsagePopover } from '@/components/usage-popover';
|
import { UsagePopover } from '@/components/usage-popover';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
|
|||||||
creatingSpecProjectPath?: string;
|
creatingSpecProjectPath?: string;
|
||||||
// Board controls props
|
// Board controls props
|
||||||
onShowBoardBackground: () => void;
|
onShowBoardBackground: () => void;
|
||||||
|
onRefreshBoard: () => Promise<void>;
|
||||||
// View toggle props
|
// View toggle props
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
onViewModeChange: (mode: ViewMode) => void;
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
@@ -60,6 +63,7 @@ export function BoardHeader({
|
|||||||
isCreatingSpec,
|
isCreatingSpec,
|
||||||
creatingSpecProjectPath,
|
creatingSpecProjectPath,
|
||||||
onShowBoardBackground,
|
onShowBoardBackground,
|
||||||
|
onRefreshBoard,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
}: BoardHeaderProps) {
|
}: BoardHeaderProps) {
|
||||||
@@ -110,9 +114,20 @@ export function BoardHeader({
|
|||||||
|
|
||||||
// State for mobile actions panel
|
// State for mobile actions panel
|
||||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||||
|
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||||
|
|
||||||
const isTablet = useIsTablet();
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
|
const handleRefreshBoard = useCallback(async () => {
|
||||||
|
if (isRefreshingBoard) return;
|
||||||
|
setIsRefreshingBoard(true);
|
||||||
|
try {
|
||||||
|
await onRefreshBoard();
|
||||||
|
} finally {
|
||||||
|
setIsRefreshingBoard(false);
|
||||||
|
}
|
||||||
|
}, [isRefreshingBoard, onRefreshBoard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -127,6 +142,22 @@ export function BoardHeader({
|
|||||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
|
{isMounted && !isTablet && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleRefreshBoard}
|
||||||
|
disabled={isRefreshingBoard}
|
||||||
|
aria-label="Refresh board state from server"
|
||||||
|
>
|
||||||
|
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
import {
|
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
AgentTaskInfo,
|
|
||||||
parseAgentContext,
|
|
||||||
formatModelName,
|
|
||||||
DEFAULT_MODEL,
|
|
||||||
} from '@/lib/agent-context-parser';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
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
|
// Agent Info Panel for non-backlog cards
|
||||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<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 { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -36,7 +36,7 @@ interface CardActionsProps {
|
|||||||
export const CardActions = memo(function CardActions({
|
export const CardActions = memo(function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
hasContext,
|
hasContext: _hasContext,
|
||||||
shortcutKey,
|
shortcutKey,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - badge component prop variations with conditional rendering
|
||||||
import { memo, useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
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 { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
@@ -28,7 +28,6 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
||||||
{/* Error badge */}
|
{/* Error badge */}
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -45,7 +44,6 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
|||||||
<p>{feature.error}</p>
|
<p>{feature.error}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -100,13 +98,11 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCurrentTime(Date.now());
|
setCurrentTime(Date.now());
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||||
@@ -138,7 +134,6 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
<div className="absolute top-2 left-2 flex items-center gap-1">
|
<div className="absolute top-2 left-2 flex items-center gap-1">
|
||||||
{/* Priority badge */}
|
{/* Priority badge */}
|
||||||
{feature.priority && (
|
{feature.priority && (
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -168,12 +163,10 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Manual verification badge */}
|
{/* Manual verification badge */}
|
||||||
{showManualVerification && (
|
{showManualVerification && (
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -190,12 +183,10 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
<p>Manual verification required</p>
|
<p>Manual verification required</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blocked badge */}
|
{/* Blocked badge */}
|
||||||
{isBlocked && (
|
{isBlocked && (
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -223,12 +214,10 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Just Finished badge */}
|
{/* Just Finished badge */}
|
||||||
{isJustFinished && (
|
{isJustFinished && (
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -245,12 +234,10 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
<p>Agent just finished working on this feature</p>
|
<p>Agent just finished working on this feature</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pipeline exclusion badge */}
|
{/* Pipeline exclusion badge */}
|
||||||
{hasPipelineExclusions && (
|
{hasPipelineExclusions && (
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
@@ -278,7 +265,6 @@ export const PriorityBadges = memo(function PriorityBadges({
|
|||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - content section prop typing with feature data extraction
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
|
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 { memo, useState } from 'react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
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 React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||||
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
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 { Feature } from '@/store/app-store';
|
||||||
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
import { AgentTaskInfo } from '@/lib/agent-context-parser';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
|
// @ts-nocheck - BaseFeature index signature causes property access type errors
|
||||||
// The `[key: string]: unknown` in BaseFeature causes property access type errors
|
|
||||||
// @ts-nocheck
|
|
||||||
import { memo, useCallback, useState, useEffect } from 'react';
|
import { memo, useCallback, useState, useEffect } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
|
||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import { RowActions, type RowActionHandlers } from './row-actions';
|
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||||
@@ -149,7 +147,6 @@ const IndicatorBadges = memo(function IndicatorBadges({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 ml-2">
|
<div className="flex items-center gap-1 ml-2">
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<Tooltip key={badge.key}>
|
<Tooltip key={badge.key}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -171,7 +168,6 @@ const IndicatorBadges = memo(function IndicatorBadges({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
|
|||||||
import { ListHeader } from './list-header';
|
import { ListHeader } from './list-header';
|
||||||
import { ListRow, sortFeatures } from './list-row';
|
import { ListRow, sortFeatures } from './list-row';
|
||||||
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
||||||
import { getStatusLabel, getStatusOrder } from './status-badge';
|
import { getStatusOrder } from './status-badge';
|
||||||
import { getColumnsWithPipeline } from '../../constants';
|
import { getColumnsWithPipeline } from '../../constants';
|
||||||
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
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 { useState, useEffect, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -27,18 +26,10 @@ import { useNavigate } from '@tanstack/react-router';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
|
||||||
useAppStore,
|
|
||||||
ModelAlias,
|
|
||||||
ThinkingLevel,
|
|
||||||
FeatureImage,
|
|
||||||
PlanningMode,
|
|
||||||
Feature,
|
|
||||||
} from '@/store/app-store';
|
|
||||||
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||||
import { supportsReasoningEffort, isClaudeModel } from '@automaker/types';
|
import { supportsReasoningEffort } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
TestingTabContent,
|
|
||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
@@ -50,15 +41,13 @@ import {
|
|||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
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 {
|
import {
|
||||||
getAncestors,
|
getAncestors,
|
||||||
formatAncestorContextForPrompt,
|
formatAncestorContextForPrompt,
|
||||||
type AncestorContext,
|
type AncestorContext,
|
||||||
} from '@automaker/dependency-resolver';
|
} from '@automaker/dependency-resolver';
|
||||||
|
|
||||||
const logger = createLogger('AddFeatureDialog');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the default work mode based on global settings and current worktree selection.
|
* Determines the default work mode based on global settings and current worktree selection.
|
||||||
*
|
*
|
||||||
@@ -179,9 +168,6 @@ export function AddFeatureDialog({
|
|||||||
// Model selection state
|
// Model selection state
|
||||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
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
|
// Planning mode state
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
@@ -270,6 +256,13 @@ export function AddFeatureDialog({
|
|||||||
allFeatures,
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
useEffect(() => {
|
||||||
|
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||||
|
setRequirePlanApproval(false);
|
||||||
|
}
|
||||||
|
}, [planningMode]);
|
||||||
|
|
||||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||||
setModelEntry(entry);
|
setModelEntry(entry);
|
||||||
};
|
};
|
||||||
@@ -528,7 +521,6 @@ export function AddFeatureDialog({
|
|||||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
<span>AI & Execution</span>
|
<span>AI & Execution</span>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -547,7 +539,6 @@ export function AddFeatureDialog({
|
|||||||
<p>Change default model and planning settings for new features</p>
|
<p>Change default model and planning settings for new features</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -562,41 +553,13 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
<div className="grid gap-3 grid-cols-2">
|
<div className="grid gap-3 grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label
|
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||||
className={cn(
|
|
||||||
'text-xs text-muted-foreground',
|
|
||||||
!modelSupportsPlanningMode && 'opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Planning
|
|
||||||
</Label>
|
|
||||||
{modelSupportsPlanningMode ? (
|
|
||||||
<PlanningModeSelect
|
<PlanningModeSelect
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
onModeChange={setPlanningMode}
|
onModeChange={setPlanningMode}
|
||||||
testIdPrefix="add-feature-planning"
|
testIdPrefix="add-feature-planning"
|
||||||
compact
|
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||||
@@ -620,20 +583,14 @@ export function AddFeatureDialog({
|
|||||||
id="add-feature-require-approval"
|
id="add-feature-require-approval"
|
||||||
checked={requirePlanApproval}
|
checked={requirePlanApproval}
|
||||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
disabled={
|
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||||
!modelSupportsPlanningMode ||
|
data-testid="add-feature-planning-require-approval-checkbox"
|
||||||
planningMode === 'skip' ||
|
|
||||||
planningMode === 'lite'
|
|
||||||
}
|
|
||||||
data-testid="add-feature-require-approval-checkbox"
|
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="add-feature-require-approval"
|
htmlFor="add-feature-require-approval"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-normal',
|
'text-xs font-normal',
|
||||||
!modelSupportsPlanningMode ||
|
planningMode === 'skip' || planningMode === 'lite'
|
||||||
planningMode === 'skip' ||
|
|
||||||
planningMode === 'lite'
|
|
||||||
? 'cursor-not-allowed text-muted-foreground'
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useAppStore } from '@/store/app-store';
|
|||||||
import { extractSummary } from '@/lib/log-parser';
|
import { extractSummary } from '@/lib/log-parser';
|
||||||
import { useAgentOutput } from '@/hooks/queries';
|
import { useAgentOutput } from '@/hooks/queries';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
import type { BacklogPlanEvent } from '@automaker/types';
|
||||||
|
|
||||||
interface AgentOutputModalProps {
|
interface AgentOutputModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,18 +49,16 @@ export function AgentOutputModal({
|
|||||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||||
|
|
||||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
|
const resolvedProjectPath = projectPathProp || window.__currentProject?.path || '';
|
||||||
|
|
||||||
// Track additional content from WebSocket events (appended to query data)
|
// Track additional content from WebSocket events (appended to query data)
|
||||||
const [streamedContent, setStreamedContent] = useState<string>('');
|
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||||
|
|
||||||
// Use React Query for initial output loading
|
// Use React Query for initial output loading
|
||||||
const { data: initialOutput = '', isLoading } = useAgentOutput(
|
const { data: initialOutput = '', isLoading } = useAgentOutput(resolvedProjectPath, featureId, {
|
||||||
resolvedProjectPath,
|
enabled: open && !!resolvedProjectPath,
|
||||||
featureId,
|
});
|
||||||
open && !!resolvedProjectPath
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset streamed content when modal opens or featureId changes
|
// Reset streamed content when modal opens or featureId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,7 +261,8 @@ export function AgentOutputModal({
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.backlogPlan) return;
|
if (!api?.backlogPlan) return;
|
||||||
|
|
||||||
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
|
const unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
|
||||||
|
const event = data as BacklogPlanEvent;
|
||||||
if (!event?.type) return;
|
if (!event?.type) return;
|
||||||
|
|
||||||
let newContent = '';
|
let newContent = '';
|
||||||
@@ -282,7 +282,7 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
setOutput((prev) => `${prev}${newContent}`);
|
setStreamedContent((prev) => prev + newContent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - completed features filtering and grouping with status transitions
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|||||||
@@ -241,9 +241,9 @@ export function CreatePRDialog({
|
|||||||
<GitPullRequest className="w-5 h-5" />
|
<GitPullRequest className="w-5 h-5" />
|
||||||
Create Pull Request
|
Create Pull Request
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="break-words">
|
||||||
Push changes and create a pull request from{' '}
|
Push changes and create a pull request from{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - dependency tree visualization with recursive feature relationships
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - form state management with partial feature updates and validation
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -26,11 +25,10 @@ import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react';
|
|||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||||
import { migrateModelId } from '@automaker/types';
|
import { migrateModelId } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
TestingTabContent,
|
|
||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
WorkModeSelector,
|
WorkModeSelector,
|
||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
@@ -41,11 +39,9 @@ import {
|
|||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
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 { DependencyTreeDialog } from './dependency-tree-dialog';
|
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||||
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
|
import { supportsReasoningEffort } from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('EditFeatureDialog');
|
|
||||||
|
|
||||||
interface EditFeatureDialogProps {
|
interface EditFeatureDialogProps {
|
||||||
feature: Feature | null;
|
feature: Feature | null;
|
||||||
@@ -119,9 +115,6 @@ export function EditFeatureDialog({
|
|||||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
|
||||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
|
||||||
|
|
||||||
// Track the source of description changes for history
|
// Track the source of description changes for history
|
||||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||||
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
|
||||||
@@ -194,6 +187,13 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
}, [feature, allFeatures]);
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
useEffect(() => {
|
||||||
|
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||||
|
setRequirePlanApproval(false);
|
||||||
|
}
|
||||||
|
}, [planningMode]);
|
||||||
|
|
||||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||||
setModelEntry(entry);
|
setModelEntry(entry);
|
||||||
};
|
};
|
||||||
@@ -420,7 +420,6 @@ export function EditFeatureDialog({
|
|||||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
<span>AI & Execution</span>
|
<span>AI & Execution</span>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -439,7 +438,6 @@ export function EditFeatureDialog({
|
|||||||
<p>Change default model and planning settings for new features</p>
|
<p>Change default model and planning settings for new features</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -454,41 +452,13 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
<div className="grid gap-3 grid-cols-2">
|
<div className="grid gap-3 grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label
|
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||||
className={cn(
|
|
||||||
'text-xs text-muted-foreground',
|
|
||||||
!modelSupportsPlanningMode && 'opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Planning
|
|
||||||
</Label>
|
|
||||||
{modelSupportsPlanningMode ? (
|
|
||||||
<PlanningModeSelect
|
<PlanningModeSelect
|
||||||
mode={planningMode}
|
mode={planningMode}
|
||||||
onModeChange={setPlanningMode}
|
onModeChange={setPlanningMode}
|
||||||
testIdPrefix="edit-feature-planning"
|
testIdPrefix="edit-feature-planning"
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<PlanningModeSelect
|
|
||||||
mode="skip"
|
|
||||||
onModeChange={() => {}}
|
|
||||||
testIdPrefix="edit-feature-planning"
|
|
||||||
compact
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Planning modes are only available for Claude Provider</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Options</Label>
|
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||||
@@ -514,20 +484,14 @@ export function EditFeatureDialog({
|
|||||||
id="edit-feature-require-approval"
|
id="edit-feature-require-approval"
|
||||||
checked={requirePlanApproval}
|
checked={requirePlanApproval}
|
||||||
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
disabled={
|
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||||
!modelSupportsPlanningMode ||
|
|
||||||
planningMode === 'skip' ||
|
|
||||||
planningMode === 'lite'
|
|
||||||
}
|
|
||||||
data-testid="edit-feature-require-approval-checkbox"
|
data-testid="edit-feature-require-approval-checkbox"
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="edit-feature-require-approval"
|
htmlFor="edit-feature-require-approval"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-normal',
|
'text-xs font-normal',
|
||||||
!modelSupportsPlanningMode ||
|
planningMode === 'skip' || planningMode === 'lite'
|
||||||
planningMode === 'skip' ||
|
|
||||||
planningMode === 'lite'
|
|
||||||
? 'cursor-not-allowed text-muted-foreground'
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -18,14 +16,7 @@ import {
|
|||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import {
|
import { EnhanceWithAI, EnhancementHistoryButton, type BaseHistoryEntry } from '../shared';
|
||||||
EnhanceWithAI,
|
|
||||||
EnhancementHistoryButton,
|
|
||||||
type EnhancementMode,
|
|
||||||
type BaseHistoryEntry,
|
|
||||||
} from '../shared';
|
|
||||||
|
|
||||||
const logger = createLogger('FollowUpDialog');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single entry in the follow-up prompt history
|
* A single entry in the follow-up prompt history
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
|
||||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
TestingTabContent,
|
TestingTabContent,
|
||||||
@@ -22,9 +21,8 @@ import {
|
|||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
import type { PhaseModelEntry } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
interface MassEditDialogProps {
|
interface MassEditDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -199,6 +197,13 @@ export function MassEditDialog({
|
|||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
useEffect(() => {
|
||||||
|
if (planningMode === 'skip' || planningMode === 'lite') {
|
||||||
|
setRequirePlanApproval(false);
|
||||||
|
}
|
||||||
|
}, [planningMode]);
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
const updates: Partial<Feature> = {};
|
const updates: Partial<Feature> = {};
|
||||||
|
|
||||||
@@ -234,9 +239,6 @@ export function MassEditDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasAnyApply = Object.values(applyState).some(Boolean);
|
const hasAnyApply = Object.values(applyState).some(Boolean);
|
||||||
const isCurrentModelCursor = isCursorModel(model);
|
|
||||||
const modelAllowsThinking = !isCurrentModelCursor && modelSupportsThinking(model);
|
|
||||||
const modelSupportsPlanningMode = isClaudeModel(model);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||||
@@ -276,7 +278,6 @@ export function MassEditDialog({
|
|||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
{/* Planning Mode */}
|
{/* Planning Mode */}
|
||||||
{modelSupportsPlanningMode ? (
|
|
||||||
<FieldWrapper
|
<FieldWrapper
|
||||||
label="Planning Mode"
|
label="Planning Mode"
|
||||||
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
isMixed={mixedValues.planningMode || mixedValues.requirePlanApproval}
|
||||||
@@ -301,39 +302,6 @@ export function MassEditDialog({
|
|||||||
testIdPrefix="mass-edit-planning"
|
testIdPrefix="mass-edit-planning"
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
) : (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-3 rounded-lg border transition-colors border-border bg-muted/20 opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox checked={false} disabled className="opacity-50" />
|
|
||||||
<Label className="text-sm font-medium text-muted-foreground">
|
|
||||||
Planning Mode
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="opacity-50 pointer-events-none">
|
|
||||||
<PlanningModeSelect
|
|
||||||
mode="skip"
|
|
||||||
onModeChange={() => {}}
|
|
||||||
testIdPrefix="mass-edit-planning"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Planning modes are only available for Claude Provider</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<FieldWrapper
|
<FieldWrapper
|
||||||
|
|||||||
@@ -23,14 +23,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
|
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||||
interface WorktreeInfo {
|
|
||||||
path: string;
|
|
||||||
branch: string;
|
|
||||||
isMain: boolean;
|
|
||||||
hasChanges?: boolean;
|
|
||||||
changedFilesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteBranch {
|
interface RemoteBranch {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -49,7 +42,7 @@ interface PullResolveConflictsDialogProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
|
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PullResolveConflictsDialog({
|
export function PullResolveConflictsDialog({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - feature update logic with partial updates and image/file handling
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +51,6 @@ export function useBoardColumnFeatures({
|
|||||||
|
|
||||||
// Determine the effective worktree path and branch for filtering
|
// Determine the effective worktree path and branch for filtering
|
||||||
// If currentWorktreePath is null, we're on the main worktree
|
// If currentWorktreePath is null, we're on the main worktree
|
||||||
const effectiveWorktreePath = currentWorktreePath || projectPath;
|
|
||||||
// Use the branch name from the selected worktree
|
// Use the branch name from the selected worktree
|
||||||
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
// If we're selecting main (currentWorktreePath is null), currentWorktreeBranch
|
||||||
// should contain the main branch's actual name, defaulting to "main"
|
// should contain the main branch's actual name, defaulting to "main"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface UseBoardDragDropProps {
|
|||||||
|
|
||||||
export function useBoardDragDrop({
|
export function useBoardDragDrop({
|
||||||
features,
|
features,
|
||||||
currentProject,
|
currentProject: _currentProject,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
@@ -128,10 +128,9 @@ export function useBoardDragDrop({
|
|||||||
const targetBranch = worktreeData.branch;
|
const targetBranch = worktreeData.branch;
|
||||||
const currentBranch = draggedFeature.branchName;
|
const currentBranch = draggedFeature.branchName;
|
||||||
|
|
||||||
// For main worktree, set branchName to null to indicate it should use main
|
// For main worktree, set branchName to undefined to indicate it should use main
|
||||||
// (must use null not undefined so it serializes to JSON for the API call)
|
|
||||||
// For other worktrees, set branchName to the target branch
|
// For other worktrees, set branchName to the target branch
|
||||||
const newBranchName = worktreeData.isMain ? null : targetBranch;
|
const newBranchName: string | undefined = worktreeData.isMain ? undefined : targetBranch;
|
||||||
|
|
||||||
// If already on the same branch, nothing to do
|
// If already on the same branch, nothing to do
|
||||||
// For main worktree: feature with null/undefined branchName is already on main
|
// For main worktree: feature with null/undefined branchName is already on main
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
const logger = createLogger('BoardEffects');
|
const logger = createLogger('BoardEffects');
|
||||||
|
|
||||||
interface UseBoardEffectsProps {
|
interface UseBoardEffectsProps {
|
||||||
currentProject: { path: string; id: string } | null;
|
currentProject: { path: string; id: string; name?: string } | null;
|
||||||
specCreatingForProject: string | null;
|
specCreatingForProject: string | null;
|
||||||
setSpecCreatingForProject: (path: string | null) => void;
|
setSpecCreatingForProject: (path: string | null) => void;
|
||||||
checkContextExists: (featureId: string) => Promise<boolean>;
|
checkContextExists: (featureId: string) => Promise<boolean>;
|
||||||
features: any[];
|
features: Feature[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
featuresWithContext: Set<string>;
|
featuresWithContext: Set<string>;
|
||||||
setFeaturesWithContext: (set: Set<string>) => void;
|
setFeaturesWithContext: (set: Set<string>) => void;
|
||||||
@@ -33,10 +34,10 @@ export function useBoardEffects({
|
|||||||
// Make current project available globally for modal
|
// Make current project available globally for modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
(window as any).__currentProject = currentProject;
|
window.__currentProject = currentProject;
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
(window as any).__currentProject = null;
|
window.__currentProject = null;
|
||||||
};
|
};
|
||||||
}, [currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
features,
|
features,
|
||||||
isLoading,
|
isLoading,
|
||||||
persistedCategories,
|
persistedCategories,
|
||||||
loadFeatures: () => {
|
loadFeatures: async () => {
|
||||||
queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
|
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { Feature as ApiFeature } from '@automaker/types';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
@@ -48,14 +49,14 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
feature: result.feature,
|
feature: result.feature,
|
||||||
});
|
});
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
const updatedFeature = result.feature;
|
const updatedFeature = result.feature as Feature;
|
||||||
updateFeature(updatedFeature.id, updatedFeature);
|
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
|
||||||
queryClient.setQueryData<Feature[]>(
|
queryClient.setQueryData<Feature[]>(
|
||||||
queryKeys.features.all(currentProject.path),
|
queryKeys.features.all(currentProject.path),
|
||||||
(features) => {
|
(features) => {
|
||||||
if (!features) return features;
|
if (!features) return features;
|
||||||
return features.map((feature) =>
|
return features.map((feature) =>
|
||||||
feature.id === updatedFeature.id ? updatedFeature : feature
|
feature.id === updatedFeature.id ? { ...feature, ...updatedFeature } : feature
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -85,9 +86,9 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.features.create(currentProject.path, feature);
|
const result = await api.features.create(currentProject.path, feature as ApiFeature);
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, result.feature);
|
updateFeature(result.feature.id, result.feature as Partial<Feature>);
|
||||||
// Invalidate React Query cache to sync UI
|
// Invalidate React Query cache to sync UI
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.features.all(currentProject.path),
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
type UIEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { DragOverlay } from '@dnd-kit/core';
|
import { DragOverlay } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
@@ -13,7 +14,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||||
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
|
||||||
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
|
import { Archive, Settings2, CheckSquare, GripVertical, Plus, CheckCircle2 } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
@@ -79,7 +80,7 @@ const REDUCED_CARD_OPACITY_PERCENT = 85;
|
|||||||
type VirtualListItem = { id: string };
|
type VirtualListItem = { id: string };
|
||||||
|
|
||||||
interface VirtualListState<Item extends VirtualListItem> {
|
interface VirtualListState<Item extends VirtualListItem> {
|
||||||
contentRef: RefObject<HTMLDivElement>;
|
contentRef: RefObject<HTMLDivElement | null>;
|
||||||
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||||
itemIds: string[];
|
itemIds: string[];
|
||||||
visibleItems: Item[];
|
visibleItems: Item[];
|
||||||
@@ -358,7 +359,6 @@ export function KanbanBoard({
|
|||||||
contentClassName="perf-contain"
|
contentClassName="perf-contain"
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' ? (
|
column.id === 'verified' ? (
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{columnFeatures.length > 0 && (
|
{columnFeatures.length > 0 && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -400,7 +400,6 @@ export function KanbanBoard({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
|
||||||
) : column.id === 'backlog' ? (
|
) : column.id === 'backlog' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ModelAlias } from '@/store/app-store';
|
|
||||||
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
CURSOR_MODEL_MAP,
|
CURSOR_MODEL_MAP,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Brain, AlertTriangle } from 'lucide-react';
|
import { Brain, AlertTriangle } from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ModelAlias } from '@/store/app-store';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
|
import { getModelProvider } from '@automaker/types';
|
||||||
import type { ModelProvider } from '@automaker/types';
|
import type { ModelProvider, CursorModelId } from '@automaker/types';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
@@ -41,6 +39,7 @@ export function ModelSelector({
|
|||||||
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
|
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
|
||||||
|
|
||||||
// Check if Codex CLI is available
|
// Check if Codex CLI is available
|
||||||
|
// @ts-expect-error - codexCliStatus uses CliStatus type but should use CodexCliStatus which has auth
|
||||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
||||||
|
|
||||||
// Fetch Codex models on mount
|
// Fetch Codex models on mount
|
||||||
@@ -76,8 +75,8 @@ export function ModelSelector({
|
|||||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||||
return (
|
return (
|
||||||
enabledCursorModels.includes(model.id as any) ||
|
enabledCursorModels.includes(model.id as CursorModelId) ||
|
||||||
enabledCursorModels.includes(unprefixedId as any)
|
enabledCursorModels.includes(unprefixedId as CursorModelId)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
interface TooltipWrapperProps {
|
interface TooltipWrapperProps {
|
||||||
/** The element to wrap with a tooltip */
|
/** The element to wrap with a tooltip */
|
||||||
@@ -29,7 +29,6 @@ export function TooltipWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
|
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
|
||||||
@@ -39,6 +38,5 @@ export function TooltipWrapper({
|
|||||||
<p>{tooltipContent}</p>
|
<p>{tooltipContent}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export function WorktreeActionsDropdown({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Get available terminals for the "Open In Terminal" submenu
|
// Get available terminals for the "Open In Terminal" submenu
|
||||||
const { terminals, hasExternalTerminals } = useAvailableTerminals();
|
const { terminals } = useAvailableTerminals();
|
||||||
|
|
||||||
// Use shared hook for effective default terminal (null = integrated terminal)
|
// Use shared hook for effective default terminal (null = integrated terminal)
|
||||||
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
|
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
|
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -101,14 +101,12 @@ export function WorktreeDropdownItem({
|
|||||||
|
|
||||||
{/* Branch name with optional tooltip */}
|
{/* Branch name with optional tooltip */}
|
||||||
{isBranchNameTruncated ? (
|
{isBranchNameTruncated ? (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
|
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="font-mono text-xs">{worktree.branch}</p>
|
<p className="font-mono text-xs">{worktree.branch}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
) : (
|
||||||
branchNameElement
|
branchNameElement
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -335,14 +335,12 @@ export function WorktreeDropdown({
|
|||||||
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
|
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
|
||||||
|
|
||||||
const triggerWithTooltip = isBranchNameTruncated ? (
|
const triggerWithTooltip = isBranchNameTruncated ? (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
|
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="font-mono text-xs">{displayBranch}</p>
|
<p className="font-mono text-xs">{displayBranch}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
) : (
|
||||||
dropdownTrigger
|
dropdownTrigger
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import type {
|
import type {
|
||||||
WorktreeInfo,
|
WorktreeInfo,
|
||||||
@@ -271,7 +271,6 @@ export function WorktreeTab({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
@@ -293,7 +292,6 @@ export function WorktreeTab({
|
|||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
{prBadge}
|
{prBadge}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -340,7 +338,6 @@ export function WorktreeTab({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
@@ -362,14 +359,12 @@ export function WorktreeTab({
|
|||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
{prBadge}
|
{prBadge}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDevServerRunning && (
|
{isDevServerRunning && (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -391,11 +386,9 @@ export function WorktreeTab({
|
|||||||
<p>Open dev server (:{devServerInfo?.port})</p>
|
<p>Open dev server (:{devServerInfo?.port})</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAutoModeRunning && (
|
{isAutoModeRunning && (
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span
|
<span
|
||||||
@@ -411,7 +404,6 @@ export function WorktreeTab({
|
|||||||
<p>Auto Mode Running</p>
|
<p>Auto Mode Running</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WorktreeActionsDropdown
|
<WorktreeActionsDropdown
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export function useAvailableEditors() {
|
|||||||
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
|
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
if (!api.worktree) {
|
||||||
|
throw new Error('Worktree API not available');
|
||||||
|
}
|
||||||
const result = await api.worktree.refreshEditors();
|
const result = await api.worktree.refreshEditors();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to refresh editors');
|
throw new Error(result.error || 'Failed to refresh editors');
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ export function WorktreePanel({
|
|||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to push changes');
|
toast.error(result.error || 'Failed to push changes');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to push changes');
|
toast.error('Failed to push changes');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { UIEvent } from 'react';
|
import type { UIEvent } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore, ChatSession } from '@/store/app-store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -156,7 +156,7 @@ export function ChatHistory() {
|
|||||||
createChatSession();
|
createChatSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSession = (session: any) => {
|
const handleSelectSession = (session: ChatSession) => {
|
||||||
setCurrentChatSession(session);
|
setCurrentChatSession(session);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useAppStore } from '@/store/app-store';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react';
|
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,6 @@ export function ContextView() {
|
|||||||
// File input ref for import
|
// File input ref for import
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Get images directory path
|
|
||||||
const getImagesPath = useCallback(() => {
|
|
||||||
if (!currentProject) return null;
|
|
||||||
return `${currentProject.path}/.automaker/images`;
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts for this view
|
// Keyboard shortcuts for this view
|
||||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - GitHub issues view with issue selection and feature creation flow
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
|
||||||
@@ -43,9 +43,6 @@ export function GitHubIssuesView() {
|
|||||||
// Model override for validation
|
// Model override for validation
|
||||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
|
|
||||||
// Extract model string for API calls (backward compatibility)
|
|
||||||
const validationModelString = validationModelOverride.effectiveModel;
|
|
||||||
|
|
||||||
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
||||||
|
|
||||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ export function IssueDetailPanel({
|
|||||||
{(() => {
|
{(() => {
|
||||||
if (isValidating) {
|
if (isValidating) {
|
||||||
return (
|
return (
|
||||||
<Button variant="default" size="sm" disabled>
|
<Button variant="default" size="sm" loading>
|
||||||
<Spinner size="sm" className="mr-1" />
|
|
||||||
Validating...
|
Validating...
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -334,16 +333,9 @@ export function IssueDetailPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={loadingMore}
|
loading={loadingMore}
|
||||||
>
|
>
|
||||||
{loadingMore ? (
|
{loadingMore ? 'Loading...' : 'Load More Comments'}
|
||||||
<>
|
|
||||||
<Spinner size="sm" className="mr-2" />
|
|
||||||
Loading...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Load More Comments'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CircleDot, RefreshCw } from 'lucide-react';
|
import { CircleDot, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { IssuesStateFilter } from '../types';
|
import type { IssuesStateFilter } from '../types';
|
||||||
import { IssuesFilterControls } from './issues-filter-controls';
|
import { IssuesFilterControls } from './issues-filter-controls';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck - GitHub issue validation with Electron API integration and async state
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
@@ -17,17 +17,6 @@ import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
|
|||||||
|
|
||||||
const logger = createLogger('IssueValidation');
|
const logger = createLogger('IssueValidation');
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract model string from PhaseModelEntry or string (handles both formats)
|
|
||||||
*/
|
|
||||||
function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined {
|
|
||||||
if (!entry) return undefined;
|
|
||||||
if (typeof entry === 'string') {
|
|
||||||
return entry as ModelId;
|
|
||||||
}
|
|
||||||
return entry.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseIssueValidationOptions {
|
interface UseIssueValidationOptions {
|
||||||
selectedIssue: GitHubIssue | null;
|
selectedIssue: GitHubIssue | null;
|
||||||
showValidationDialog: boolean;
|
showValidationDialog: boolean;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user