mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
merge: integrate v0.13.0rc with React Query refactor
Resolved conflict in use-project-settings-loader.ts: - Keep React Query approach from upstream - Add phaseModelOverrides loading for provider model persistence - Update both currentProject and projects array to keep in sync
This commit is contained in:
300
SECURITY_TODO.md
Normal file
300
SECURITY_TODO.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# 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
|
||||||
8
TODO.md
8
TODO.md
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
- Setting the default model does not seem like it works.
|
- 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
|
# UX
|
||||||
|
|
||||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ notificationService.setEventEmitter(events);
|
|||||||
const eventHistoryService = getEventHistoryService();
|
const eventHistoryService = getEventHistoryService();
|
||||||
|
|
||||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||||
eventHookService.initialize(events, settingsService, eventHistoryService);
|
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check per-worktree capacity before starting
|
||||||
|
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
||||||
|
if (!capacity.hasCapacity) {
|
||||||
|
const worktreeDesc = capacity.branchName
|
||||||
|
? `worktree "${capacity.branchName}"`
|
||||||
|
: 'main worktree';
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
||||||
|
details: {
|
||||||
|
currentAgents: capacity.currentAgents,
|
||||||
|
maxAgents: capacity.maxAgents,
|
||||||
|
branchName: capacity.branchName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Start execution in background
|
// Start execution in background
|
||||||
// executeFeature derives workDir from feature.branchName
|
// executeFeature derives workDir from feature.branchName
|
||||||
autoModeService
|
autoModeService
|
||||||
|
|||||||
@@ -85,8 +85,9 @@ export function createApplyHandler() {
|
|||||||
if (!change.feature) continue;
|
if (!change.feature) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create the new feature
|
// Create the new feature - use the AI-generated ID if provided
|
||||||
const newFeature = await featureLoader.create(projectPath, {
|
const newFeature = await featureLoader.create(projectPath, {
|
||||||
|
id: change.feature.id, // Use descriptive ID from AI if provided
|
||||||
title: change.feature.title,
|
title: change.feature.title,
|
||||||
description: change.feature.description || '',
|
description: change.feature.description || '',
|
||||||
category: change.feature.category || 'Uncategorized',
|
category: change.feature.category || 'Uncategorized',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
createRunInitScriptHandler,
|
createRunInitScriptHandler,
|
||||||
} from './routes/init-script.js';
|
} from './routes/init-script.js';
|
||||||
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||||
|
import { createListRemotesHandler } from './routes/list-remotes.js';
|
||||||
import type { SettingsService } from '../../services/settings-service.js';
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
|
||||||
export function createWorktreeRoutes(
|
export function createWorktreeRoutes(
|
||||||
@@ -157,5 +158,13 @@ export function createWorktreeRoutes(
|
|||||||
createDiscardChangesHandler()
|
createDiscardChangesHandler()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// List remotes route
|
||||||
|
router.post(
|
||||||
|
'/list-remotes',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireValidWorktree,
|
||||||
|
createListRemotesHandler()
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,9 +110,10 @@ export function createListBranchesHandler() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ahead/behind count for current branch
|
// Get ahead/behind count for current branch and check if remote branch exists
|
||||||
let aheadCount = 0;
|
let aheadCount = 0;
|
||||||
let behindCount = 0;
|
let behindCount = 0;
|
||||||
|
let hasRemoteBranch = false;
|
||||||
try {
|
try {
|
||||||
// First check if there's a remote tracking branch
|
// First check if there's a remote tracking branch
|
||||||
const { stdout: upstreamOutput } = await execAsync(
|
const { stdout: upstreamOutput } = await execAsync(
|
||||||
@@ -121,6 +122,7 @@ export function createListBranchesHandler() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (upstreamOutput.trim()) {
|
if (upstreamOutput.trim()) {
|
||||||
|
hasRemoteBranch = true;
|
||||||
const { stdout: aheadBehindOutput } = await execAsync(
|
const { stdout: aheadBehindOutput } = await execAsync(
|
||||||
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
|
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
|
||||||
{ cwd: worktreePath }
|
{ cwd: worktreePath }
|
||||||
@@ -130,7 +132,18 @@ export function createListBranchesHandler() {
|
|||||||
behindCount = behind || 0;
|
behindCount = behind || 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No upstream branch set, that's okay
|
// No upstream branch set - check if the branch exists on any remote
|
||||||
|
try {
|
||||||
|
// Check if there's a matching branch on origin (most common remote)
|
||||||
|
const { stdout: remoteBranchOutput } = await execAsync(
|
||||||
|
`git ls-remote --heads origin ${currentBranch}`,
|
||||||
|
{ cwd: worktreePath, timeout: 5000 }
|
||||||
|
);
|
||||||
|
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
|
||||||
|
} catch {
|
||||||
|
// No remote branch found or origin doesn't exist
|
||||||
|
hasRemoteBranch = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -140,6 +153,7 @@ export function createListBranchesHandler() {
|
|||||||
branches,
|
branches,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
hasRemoteBranch,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal file
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* POST /list-remotes endpoint - List all remotes and their branches
|
||||||
|
*
|
||||||
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
|
* the requireValidWorktree middleware in index.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
interface RemoteBranch {
|
||||||
|
name: string;
|
||||||
|
fullRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteInfo {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
branches: RemoteBranch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createListRemotesHandler() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as {
|
||||||
|
worktreePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of remotes
|
||||||
|
const { stdout: remotesOutput } = await execAsync('git remote -v', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse remotes (each remote appears twice - once for fetch, once for push)
|
||||||
|
const remotesSet = new Map<string, string>();
|
||||||
|
remotesOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.forEach((line) => {
|
||||||
|
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
||||||
|
if (match) {
|
||||||
|
remotesSet.set(match[1], match[2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch latest from all remotes (silently, don't fail if offline)
|
||||||
|
try {
|
||||||
|
await execAsync('git fetch --all --quiet', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
timeout: 15000, // 15 second timeout
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore fetch errors - we'll use cached remote refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all remote branches
|
||||||
|
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||||
|
'git branch -r --format="%(refname:short)"',
|
||||||
|
{ cwd: worktreePath }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group branches by remote
|
||||||
|
const remotesBranches = new Map<string, RemoteBranch[]>();
|
||||||
|
remotesSet.forEach((_, remoteName) => {
|
||||||
|
remotesBranches.set(remoteName, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
remoteBranchesOutput
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.forEach((line) => {
|
||||||
|
const cleanLine = line.trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
// Skip HEAD pointers like "origin/HEAD"
|
||||||
|
if (cleanLine.includes('/HEAD')) return;
|
||||||
|
|
||||||
|
// Parse remote name from branch ref (e.g., "origin/main" -> "origin")
|
||||||
|
const slashIndex = cleanLine.indexOf('/');
|
||||||
|
if (slashIndex === -1) return;
|
||||||
|
|
||||||
|
const remoteName = cleanLine.substring(0, slashIndex);
|
||||||
|
const branchName = cleanLine.substring(slashIndex + 1);
|
||||||
|
|
||||||
|
if (remotesBranches.has(remoteName)) {
|
||||||
|
remotesBranches.get(remoteName)!.push({
|
||||||
|
name: branchName,
|
||||||
|
fullRef: cleanLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build final result
|
||||||
|
const remotes: RemoteInfo[] = [];
|
||||||
|
remotesSet.forEach((url, name) => {
|
||||||
|
remotes.push({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
branches: remotesBranches.get(name) || [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
remotes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const worktreePath = req.body?.worktreePath;
|
||||||
|
logWorktreeError(error, 'List remotes failed', worktreePath);
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
* POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
|
||||||
|
*
|
||||||
|
* Allows merging a worktree branch into any target branch (defaults to 'main').
|
||||||
*
|
*
|
||||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||||
* the requireValidProject middleware in index.ts
|
* the requireValidProject middleware in index.ts
|
||||||
@@ -8,18 +10,21 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
const logger = createLogger('Worktree');
|
||||||
|
|
||||||
export function createMergeHandler() {
|
export function createMergeHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, branchName, worktreePath, options } = req.body as {
|
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
branchName: string;
|
branchName: string;
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
options?: { squash?: boolean; message?: string };
|
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
||||||
|
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!projectPath || !branchName || !worktreePath) {
|
if (!projectPath || !branchName || !worktreePath) {
|
||||||
@@ -30,7 +35,10 @@ export function createMergeHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch exists
|
// Determine the target branch (default to 'main')
|
||||||
|
const mergeTo = targetBranch || 'main';
|
||||||
|
|
||||||
|
// Validate source branch exists
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -41,12 +49,44 @@ export function createMergeHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge the feature branch
|
// Validate target branch exists
|
||||||
|
try {
|
||||||
|
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Target branch "${mergeTo}" does not exist`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the feature branch into the target branch
|
||||||
const mergeCmd = options?.squash
|
const mergeCmd = options?.squash
|
||||||
? `git merge --squash ${branchName}`
|
? `git merge --squash ${branchName}`
|
||||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
|
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
await execAsync(mergeCmd, { cwd: projectPath });
|
await execAsync(mergeCmd, { cwd: projectPath });
|
||||||
|
} catch (mergeError: unknown) {
|
||||||
|
// Check if this is a merge conflict
|
||||||
|
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
||||||
|
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
||||||
|
const hasConflicts =
|
||||||
|
output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
// Return conflict-specific error message that frontend can detect
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
||||||
|
hasConflicts: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw non-conflict errors to be handled by outer catch
|
||||||
|
throw mergeError;
|
||||||
|
}
|
||||||
|
|
||||||
// If squash merge, need to commit
|
// If squash merge, need to commit
|
||||||
if (options?.squash) {
|
if (options?.squash) {
|
||||||
@@ -55,17 +95,46 @@ export function createMergeHandler() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up worktree and branch
|
// Optionally delete the worktree and branch after merging
|
||||||
|
let worktreeDeleted = false;
|
||||||
|
let branchDeleted = false;
|
||||||
|
|
||||||
|
if (options?.deleteWorktreeAndBranch) {
|
||||||
|
// Remove the worktree
|
||||||
try {
|
try {
|
||||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||||
cwd: projectPath,
|
worktreeDeleted = true;
|
||||||
});
|
|
||||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
|
||||||
} catch {
|
} catch {
|
||||||
// Cleanup errors are non-fatal
|
// Try with prune if remove fails
|
||||||
|
try {
|
||||||
|
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||||
|
worktreeDeleted = true;
|
||||||
|
} catch {
|
||||||
|
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, mergedBranch: branchName });
|
// Delete the branch (but not main/master)
|
||||||
|
if (branchName !== 'main' && branchName !== 'master') {
|
||||||
|
if (!isValidBranchName(branchName)) {
|
||||||
|
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||||
|
branchDeleted = true;
|
||||||
|
} catch {
|
||||||
|
logger.warn(`Failed to delete branch: ${branchName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
mergedBranch: branchName,
|
||||||
|
targetBranch: mergeTo,
|
||||||
|
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Merge worktree failed');
|
logError(error, 'Merge worktree failed');
|
||||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
|
|||||||
export function createPushHandler() {
|
export function createPushHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { worktreePath, force } = req.body as {
|
const { worktreePath, force, remote } = req.body as {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
remote?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
@@ -34,15 +35,18 @@ export function createPushHandler() {
|
|||||||
});
|
});
|
||||||
const branchName = branchOutput.trim();
|
const branchName = branchOutput.trim();
|
||||||
|
|
||||||
|
// Use specified remote or default to 'origin'
|
||||||
|
const targetRemote = remote || 'origin';
|
||||||
|
|
||||||
// Push the branch
|
// Push the branch
|
||||||
const forceFlag = force ? '--force' : '';
|
const forceFlag = force ? '--force' : '';
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
|
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Try setting upstream
|
// Try setting upstream
|
||||||
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
|
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -52,7 +56,7 @@ export function createPushHandler() {
|
|||||||
result: {
|
result: {
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
pushed: true,
|
pushed: true,
|
||||||
message: `Successfully pushed ${branchName} to origin`,
|
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -249,7 +249,8 @@ interface AutoModeConfig {
|
|||||||
* @param branchName - The branch name, or null for main worktree
|
* @param branchName - The branch name, or null for main worktree
|
||||||
*/
|
*/
|
||||||
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
||||||
return `${projectPath}::${branchName ?? '__main__'}`;
|
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||||
|
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -515,14 +516,11 @@ export class AutoModeService {
|
|||||||
? settings.maxConcurrency
|
? settings.maxConcurrency
|
||||||
: DEFAULT_MAX_CONCURRENCY;
|
: DEFAULT_MAX_CONCURRENCY;
|
||||||
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
|
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
|
||||||
const autoModeByWorktree = (settings as unknown as Record<string, unknown>)
|
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||||
.autoModeByWorktree;
|
|
||||||
|
|
||||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||||
const key = `${projectId}::${branchName ?? '__main__'}`;
|
const key = `${projectId}::${branchName ?? '__main__'}`;
|
||||||
const entry = (autoModeByWorktree as Record<string, unknown>)[key] as
|
const entry = autoModeByWorktree[key];
|
||||||
| { maxConcurrency?: number }
|
|
||||||
| undefined;
|
|
||||||
if (entry && typeof entry.maxConcurrency === 'number') {
|
if (entry && typeof entry.maxConcurrency === 'number') {
|
||||||
return entry.maxConcurrency;
|
return entry.maxConcurrency;
|
||||||
}
|
}
|
||||||
@@ -593,6 +591,7 @@ export class AutoModeService {
|
|||||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||||
projectPath,
|
projectPath,
|
||||||
branchName,
|
branchName,
|
||||||
|
maxConcurrency: resolvedMaxConcurrency,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save execution state for recovery after restart
|
// Save execution state for recovery after restart
|
||||||
@@ -678,8 +677,10 @@ export class AutoModeService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a feature not currently running
|
// Find a feature not currently running and not yet finished
|
||||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
const nextFeature = pendingFeatures.find(
|
||||||
|
(f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f)
|
||||||
|
);
|
||||||
|
|
||||||
if (nextFeature) {
|
if (nextFeature) {
|
||||||
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
||||||
@@ -731,11 +732,12 @@ export class AutoModeService {
|
|||||||
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
|
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
|
||||||
*/
|
*/
|
||||||
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
|
||||||
|
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [, feature] of this.runningFeatures) {
|
for (const [, feature] of this.runningFeatures) {
|
||||||
// Filter by project path AND branchName to get accurate worktree-specific count
|
// Filter by project path AND branchName to get accurate worktree-specific count
|
||||||
const featureBranch = feature.branchName ?? null;
|
const featureBranch = feature.branchName ?? null;
|
||||||
if (branchName === null) {
|
if (normalizedBranch === null) {
|
||||||
// Main worktree: match features with branchName === null OR branchName === "main"
|
// Main worktree: match features with branchName === null OR branchName === "main"
|
||||||
if (
|
if (
|
||||||
feature.projectPath === projectPath &&
|
feature.projectPath === projectPath &&
|
||||||
@@ -999,6 +1001,41 @@ export class AutoModeService {
|
|||||||
return this.runningFeatures.size;
|
return this.runningFeatures.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's capacity to start a feature on a worktree.
|
||||||
|
* This respects per-worktree agent limits from autoModeByWorktree settings.
|
||||||
|
*
|
||||||
|
* @param projectPath - The main project path
|
||||||
|
* @param featureId - The feature ID to check capacity for
|
||||||
|
* @returns Object with hasCapacity boolean and details about current/max agents
|
||||||
|
*/
|
||||||
|
async checkWorktreeCapacity(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<{
|
||||||
|
hasCapacity: boolean;
|
||||||
|
currentAgents: number;
|
||||||
|
maxAgents: number;
|
||||||
|
branchName: string | null;
|
||||||
|
}> {
|
||||||
|
// Load feature to get branchName
|
||||||
|
const feature = await this.loadFeature(projectPath, featureId);
|
||||||
|
const branchName = feature?.branchName ?? null;
|
||||||
|
|
||||||
|
// Get per-worktree limit
|
||||||
|
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
|
||||||
|
|
||||||
|
// Get current running count for this worktree
|
||||||
|
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasCapacity: currentAgents < maxAgents,
|
||||||
|
currentAgents,
|
||||||
|
maxAgents,
|
||||||
|
branchName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single feature
|
* Execute a single feature
|
||||||
* @param projectPath - The main project path
|
* @param projectPath - The main project path
|
||||||
@@ -1037,7 +1074,6 @@ export class AutoModeService {
|
|||||||
if (isAutoMode) {
|
if (isAutoMode) {
|
||||||
await this.saveExecutionState(projectPath);
|
await this.saveExecutionState(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declare feature outside try block so it's available in catch for error reporting
|
// Declare feature outside try block so it's available in catch for error reporting
|
||||||
let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null;
|
let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null;
|
||||||
|
|
||||||
@@ -1045,9 +1081,44 @@ export class AutoModeService {
|
|||||||
// Validate that project path is allowed using centralized validation
|
// Validate that project path is allowed using centralized validation
|
||||||
validateWorkingDirectory(projectPath);
|
validateWorkingDirectory(projectPath);
|
||||||
|
|
||||||
|
// Load feature details FIRST to get status and plan info
|
||||||
|
feature = await this.loadFeature(projectPath, featureId);
|
||||||
|
if (!feature) {
|
||||||
|
throw new Error(`Feature ${featureId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if feature has existing context - if so, resume instead of starting fresh
|
// Check if feature has existing context - if so, resume instead of starting fresh
|
||||||
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
|
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
|
||||||
if (!options?.continuationPrompt) {
|
if (!options?.continuationPrompt) {
|
||||||
|
// If feature has an approved plan but we don't have a continuation prompt yet,
|
||||||
|
// we should build one to ensure it proceeds with multi-agent execution
|
||||||
|
if (feature.planSpec?.status === 'approved') {
|
||||||
|
logger.info(`Feature ${featureId} has approved plan, building continuation prompt`);
|
||||||
|
|
||||||
|
// Get customized prompts from settings
|
||||||
|
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||||
|
const planContent = feature.planSpec.content || '';
|
||||||
|
|
||||||
|
// Build continuation prompt using centralized template
|
||||||
|
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
|
||||||
|
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, '');
|
||||||
|
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
|
||||||
|
|
||||||
|
// Recursively call executeFeature with the continuation prompt
|
||||||
|
// Remove from running features temporarily, it will be added back
|
||||||
|
this.runningFeatures.delete(featureId);
|
||||||
|
return this.executeFeature(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
useWorktrees,
|
||||||
|
isAutoMode,
|
||||||
|
providedWorktreePath,
|
||||||
|
{
|
||||||
|
continuationPrompt,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasExistingContext = await this.contextExists(projectPath, featureId);
|
const hasExistingContext = await this.contextExists(projectPath, featureId);
|
||||||
if (hasExistingContext) {
|
if (hasExistingContext) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1059,12 +1130,6 @@ export class AutoModeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load feature details FIRST to get branchName
|
|
||||||
feature = await this.loadFeature(projectPath, featureId);
|
|
||||||
if (!feature) {
|
|
||||||
throw new Error(`Feature ${featureId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive workDir from feature.branchName
|
// Derive workDir from feature.branchName
|
||||||
// Worktrees should already be created when the feature is added/edited
|
// Worktrees should already be created when the feature is added/edited
|
||||||
let worktreePath: string | null = null;
|
let worktreePath: string | null = null;
|
||||||
@@ -1191,6 +1256,7 @@ export class AutoModeService {
|
|||||||
systemPrompt: combinedSystemPrompt || undefined,
|
systemPrompt: combinedSystemPrompt || undefined,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
thinkingLevel: feature.thinkingLevel,
|
thinkingLevel: feature.thinkingLevel,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1362,6 +1428,7 @@ export class AutoModeService {
|
|||||||
|
|
||||||
this.emitAutoModeEvent('auto_mode_progress', {
|
this.emitAutoModeEvent('auto_mode_progress', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
@@ -2816,6 +2883,21 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isFeatureFinished(feature: Feature): boolean {
|
||||||
|
const isCompleted = feature.status === 'completed' || feature.status === 'verified';
|
||||||
|
|
||||||
|
// Even if marked as completed, if it has an approved plan with pending tasks, it's not finished
|
||||||
|
if (feature.planSpec?.status === 'approved') {
|
||||||
|
const tasksCompleted = feature.planSpec.tasksCompleted ?? 0;
|
||||||
|
const tasksTotal = feature.planSpec.tasksTotal ?? 0;
|
||||||
|
if (tasksCompleted < tasksTotal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the planSpec of a feature
|
* Update the planSpec of a feature
|
||||||
*/
|
*/
|
||||||
@@ -2910,10 +2992,14 @@ Format your response as a structured markdown document.`;
|
|||||||
allFeatures.push(feature);
|
allFeatures.push(feature);
|
||||||
|
|
||||||
// Track pending features separately, filtered by worktree/branch
|
// Track pending features separately, filtered by worktree/branch
|
||||||
|
// Note: waiting_approval is NOT included - those features have completed execution
|
||||||
|
// and are waiting for user review, they should not be picked up again
|
||||||
if (
|
if (
|
||||||
feature.status === 'pending' ||
|
feature.status === 'pending' ||
|
||||||
feature.status === 'ready' ||
|
feature.status === 'ready' ||
|
||||||
feature.status === 'backlog'
|
feature.status === 'backlog' ||
|
||||||
|
(feature.planSpec?.status === 'approved' &&
|
||||||
|
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
|
||||||
) {
|
) {
|
||||||
// Filter by branchName:
|
// Filter by branchName:
|
||||||
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
|
||||||
@@ -2945,7 +3031,7 @@ Format your response as a structured markdown document.`;
|
|||||||
|
|
||||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||||
logger.info(
|
logger.info(
|
||||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}`
|
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pendingFeatures.length === 0) {
|
if (pendingFeatures.length === 0) {
|
||||||
@@ -2954,7 +3040,12 @@ Format your response as a structured markdown document.`;
|
|||||||
);
|
);
|
||||||
// Log all backlog features to help debug branchName matching
|
// Log all backlog features to help debug branchName matching
|
||||||
const allBacklogFeatures = allFeatures.filter(
|
const allBacklogFeatures = allFeatures.filter(
|
||||||
(f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready'
|
(f) =>
|
||||||
|
f.status === 'backlog' ||
|
||||||
|
f.status === 'pending' ||
|
||||||
|
f.status === 'ready' ||
|
||||||
|
(f.planSpec?.status === 'approved' &&
|
||||||
|
(f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
|
||||||
);
|
);
|
||||||
if (allBacklogFeatures.length > 0) {
|
if (allBacklogFeatures.length > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -2964,7 +3055,43 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply dependency-aware ordering
|
// Apply dependency-aware ordering
|
||||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
|
||||||
|
|
||||||
|
// Remove missing dependencies from features and save them
|
||||||
|
// This allows features to proceed when their dependencies have been deleted or don't exist
|
||||||
|
if (missingDependencies.size > 0) {
|
||||||
|
for (const [featureId, missingDepIds] of missingDependencies) {
|
||||||
|
const feature = pendingFeatures.find((f) => f.id === featureId);
|
||||||
|
if (feature && feature.dependencies) {
|
||||||
|
// Filter out the missing dependency IDs
|
||||||
|
const validDependencies = feature.dependencies.filter(
|
||||||
|
(depId) => !missingDepIds.includes(depId)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the feature in memory
|
||||||
|
feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
|
||||||
|
|
||||||
|
// Save the updated feature to disk
|
||||||
|
try {
|
||||||
|
await this.featureLoader.update(projectPath, featureId, {
|
||||||
|
dependencies: feature.dependencies,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
`[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get skipVerificationInAutoMode setting
|
// Get skipVerificationInAutoMode setting
|
||||||
const settings = await this.settingsService?.getGlobalSettings();
|
const settings = await this.settingsService?.getGlobalSettings();
|
||||||
@@ -3140,9 +3267,11 @@ You can use the Read tool to view these images at any time during implementation
|
|||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
|
branchName?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const finalProjectPath = options?.projectPath || projectPath;
|
const finalProjectPath = options?.projectPath || projectPath;
|
||||||
|
const branchName = options?.branchName ?? null;
|
||||||
const planningMode = options?.planningMode || 'skip';
|
const planningMode = options?.planningMode || 'skip';
|
||||||
const previousContent = options?.previousContent;
|
const previousContent = options?.previousContent;
|
||||||
|
|
||||||
@@ -3528,6 +3657,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
this.emitAutoModeEvent('plan_approval_required', {
|
this.emitAutoModeEvent('plan_approval_required', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
planContent: currentPlanContent,
|
planContent: currentPlanContent,
|
||||||
planningMode,
|
planningMode,
|
||||||
planVersion,
|
planVersion,
|
||||||
@@ -3559,6 +3689,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
this.emitAutoModeEvent('plan_approved', {
|
this.emitAutoModeEvent('plan_approved', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
hasEdits: !!approvalResult.editedPlan,
|
hasEdits: !!approvalResult.editedPlan,
|
||||||
planVersion,
|
planVersion,
|
||||||
});
|
});
|
||||||
@@ -3587,6 +3718,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
this.emitAutoModeEvent('plan_revision_requested', {
|
this.emitAutoModeEvent('plan_revision_requested', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
feedback: approvalResult.feedback,
|
feedback: approvalResult.feedback,
|
||||||
hasEdits: !!hasEdits,
|
hasEdits: !!hasEdits,
|
||||||
planVersion,
|
planVersion,
|
||||||
@@ -3690,6 +3822,7 @@ After generating the revised spec, output:
|
|||||||
this.emitAutoModeEvent('plan_auto_approved', {
|
this.emitAutoModeEvent('plan_auto_approved', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
planContent,
|
planContent,
|
||||||
planningMode,
|
planningMode,
|
||||||
});
|
});
|
||||||
@@ -3740,6 +3873,7 @@ After generating the revised spec, output:
|
|||||||
this.emitAutoModeEvent('auto_mode_task_started', {
|
this.emitAutoModeEvent('auto_mode_task_started', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskDescription: task.description,
|
taskDescription: task.description,
|
||||||
taskIndex,
|
taskIndex,
|
||||||
@@ -3785,11 +3919,13 @@ After generating the revised spec, output:
|
|||||||
responseText += block.text || '';
|
responseText += block.text || '';
|
||||||
this.emitAutoModeEvent('auto_mode_progress', {
|
this.emitAutoModeEvent('auto_mode_progress', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
});
|
});
|
||||||
} else if (block.type === 'tool_use') {
|
} else if (block.type === 'tool_use') {
|
||||||
this.emitAutoModeEvent('auto_mode_tool', {
|
this.emitAutoModeEvent('auto_mode_tool', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
tool: block.name,
|
tool: block.name,
|
||||||
input: block.input,
|
input: block.input,
|
||||||
});
|
});
|
||||||
@@ -3808,6 +3944,7 @@ After generating the revised spec, output:
|
|||||||
this.emitAutoModeEvent('auto_mode_task_complete', {
|
this.emitAutoModeEvent('auto_mode_task_complete', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
tasksCompleted: taskIndex + 1,
|
tasksCompleted: taskIndex + 1,
|
||||||
tasksTotal: parsedTasks.length,
|
tasksTotal: parsedTasks.length,
|
||||||
@@ -3828,6 +3965,7 @@ After generating the revised spec, output:
|
|||||||
this.emitAutoModeEvent('auto_mode_phase_complete', {
|
this.emitAutoModeEvent('auto_mode_phase_complete', {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
branchName,
|
||||||
phaseNumber: parseInt(phaseMatch[1], 10),
|
phaseNumber: parseInt(phaseMatch[1], 10),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3877,11 +4015,13 @@ After generating the revised spec, output:
|
|||||||
responseText += block.text || '';
|
responseText += block.text || '';
|
||||||
this.emitAutoModeEvent('auto_mode_progress', {
|
this.emitAutoModeEvent('auto_mode_progress', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
});
|
});
|
||||||
} else if (block.type === 'tool_use') {
|
} else if (block.type === 'tool_use') {
|
||||||
this.emitAutoModeEvent('auto_mode_tool', {
|
this.emitAutoModeEvent('auto_mode_tool', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
tool: block.name,
|
tool: block.name,
|
||||||
input: block.input,
|
input: block.input,
|
||||||
});
|
});
|
||||||
@@ -3907,6 +4047,7 @@ After generating the revised spec, output:
|
|||||||
);
|
);
|
||||||
this.emitAutoModeEvent('auto_mode_progress', {
|
this.emitAutoModeEvent('auto_mode_progress', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
content: block.text,
|
content: block.text,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3914,6 +4055,7 @@ After generating the revised spec, output:
|
|||||||
// Emit event for real-time UI
|
// Emit event for real-time UI
|
||||||
this.emitAutoModeEvent('auto_mode_tool', {
|
this.emitAutoModeEvent('auto_mode_tool', {
|
||||||
featureId,
|
featureId,
|
||||||
|
branchName,
|
||||||
tool: block.name,
|
tool: block.name,
|
||||||
input: block.input,
|
input: block.input,
|
||||||
});
|
});
|
||||||
@@ -4319,6 +4461,7 @@ After generating the revised spec, output:
|
|||||||
id: f.id,
|
id: f.id,
|
||||||
title: f.title,
|
title: f.title,
|
||||||
status: f.status,
|
status: f.status,
|
||||||
|
branchName: f.branchName ?? null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils';
|
|||||||
import type { EventEmitter } from '../lib/events.js';
|
import type { EventEmitter } from '../lib/events.js';
|
||||||
import type { SettingsService } from './settings-service.js';
|
import type { SettingsService } from './settings-service.js';
|
||||||
import type { EventHistoryService } from './event-history-service.js';
|
import type { EventHistoryService } from './event-history-service.js';
|
||||||
|
import type { FeatureLoader } from './feature-loader.js';
|
||||||
import type {
|
import type {
|
||||||
EventHook,
|
EventHook,
|
||||||
EventHookTrigger,
|
EventHookTrigger,
|
||||||
@@ -84,19 +85,22 @@ export class EventHookService {
|
|||||||
private emitter: EventEmitter | null = null;
|
private emitter: EventEmitter | null = null;
|
||||||
private settingsService: SettingsService | null = null;
|
private settingsService: SettingsService | null = null;
|
||||||
private eventHistoryService: EventHistoryService | null = null;
|
private eventHistoryService: EventHistoryService | null = null;
|
||||||
|
private featureLoader: FeatureLoader | null = null;
|
||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the service with event emitter, settings service, and event history service
|
* Initialize the service with event emitter, settings service, event history service, and feature loader
|
||||||
*/
|
*/
|
||||||
initialize(
|
initialize(
|
||||||
emitter: EventEmitter,
|
emitter: EventEmitter,
|
||||||
settingsService: SettingsService,
|
settingsService: SettingsService,
|
||||||
eventHistoryService?: EventHistoryService
|
eventHistoryService?: EventHistoryService,
|
||||||
|
featureLoader?: FeatureLoader
|
||||||
): void {
|
): void {
|
||||||
this.emitter = emitter;
|
this.emitter = emitter;
|
||||||
this.settingsService = settingsService;
|
this.settingsService = settingsService;
|
||||||
this.eventHistoryService = eventHistoryService || null;
|
this.eventHistoryService = eventHistoryService || null;
|
||||||
|
this.featureLoader = featureLoader || null;
|
||||||
|
|
||||||
// Subscribe to events
|
// Subscribe to events
|
||||||
this.unsubscribe = emitter.subscribe((type, payload) => {
|
this.unsubscribe = emitter.subscribe((type, payload) => {
|
||||||
@@ -121,6 +125,7 @@ export class EventHookService {
|
|||||||
this.emitter = null;
|
this.emitter = null;
|
||||||
this.settingsService = null;
|
this.settingsService = null;
|
||||||
this.eventHistoryService = null;
|
this.eventHistoryService = null;
|
||||||
|
this.featureLoader = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +155,19 @@ export class EventHookService {
|
|||||||
|
|
||||||
if (!trigger) return;
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// Load feature name if we have featureId but no featureName
|
||||||
|
let featureName: string | undefined = undefined;
|
||||||
|
if (payload.featureId && payload.projectPath && this.featureLoader) {
|
||||||
|
try {
|
||||||
|
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
|
||||||
|
if (feature?.title) {
|
||||||
|
featureName = feature.title;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build context for variable substitution
|
// Build context for variable substitution
|
||||||
const context: HookContext = {
|
const context: HookContext = {
|
||||||
featureId: payload.featureId,
|
featureId: payload.featureId,
|
||||||
@@ -315,6 +333,7 @@ export class EventHookService {
|
|||||||
eventType: context.eventType,
|
eventType: context.eventType,
|
||||||
timestamp: context.timestamp,
|
timestamp: context.timestamp,
|
||||||
featureId: context.featureId,
|
featureId: context.featureId,
|
||||||
|
featureName: context.featureName,
|
||||||
projectPath: context.projectPath,
|
projectPath: context.projectPath,
|
||||||
projectName: context.projectName,
|
projectName: context.projectName,
|
||||||
error: context.error,
|
error: context.error,
|
||||||
|
|||||||
@@ -574,16 +574,25 @@ export class SettingsService {
|
|||||||
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
||||||
|
|
||||||
// Empty object overwrite guard
|
// Empty object overwrite guard
|
||||||
|
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||||
|
const nextVal = sanitizedUpdates[key] as unknown;
|
||||||
|
const curVal = current[key] as unknown;
|
||||||
if (
|
if (
|
||||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
nextVal &&
|
||||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
typeof nextVal === 'object' &&
|
||||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
!Array.isArray(nextVal) &&
|
||||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
Object.keys(nextVal).length === 0 &&
|
||||||
current.lastSelectedSessionByProject &&
|
curVal &&
|
||||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
typeof curVal === 'object' &&
|
||||||
|
!Array.isArray(curVal) &&
|
||||||
|
Object.keys(curVal).length > 0
|
||||||
) {
|
) {
|
||||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
delete sanitizedUpdates[key];
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
|
||||||
|
ignoreEmptyObjectOverwrite('autoModeByWorktree');
|
||||||
|
|
||||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||||
if (attemptedProjectWipe) {
|
if (attemptedProjectWipe) {
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
"@radix-ui/react-switch": "1.2.6",
|
"@radix-ui/react-switch": "1.2.6",
|
||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@tanstack/react-query": "5.90.12",
|
"@tanstack/react-query": "^5.90.17",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.2",
|
||||||
"@tanstack/react-router": "1.141.6",
|
"@tanstack/react-router": "1.141.6",
|
||||||
"@uiw/react-codemirror": "4.25.4",
|
"@uiw/react-codemirror": "4.25.4",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
|
|||||||
@@ -1,115 +1,40 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
/**
|
||||||
|
* Claude Usage Popover
|
||||||
|
*
|
||||||
|
* Displays Claude API usage statistics using React Query for data fetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } 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 { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useClaudeUsage } from '@/hooks/queries';
|
||||||
// Error codes for distinguishing failure modes
|
|
||||||
const ERROR_CODES = {
|
|
||||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
|
||||||
AUTH_ERROR: 'AUTH_ERROR',
|
|
||||||
TRUST_PROMPT: 'TRUST_PROMPT',
|
|
||||||
UNKNOWN: 'UNKNOWN',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
|
||||||
|
|
||||||
type UsageError = {
|
|
||||||
code: ErrorCode;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fixed refresh interval (45 seconds)
|
|
||||||
const REFRESH_INTERVAL_SECONDS = 45;
|
|
||||||
|
|
||||||
export function ClaudeUsagePopover() {
|
export function ClaudeUsagePopover() {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<UsageError | null>(null);
|
|
||||||
|
|
||||||
// Check if CLI is verified/authenticated
|
// Check if CLI is verified/authenticated
|
||||||
const isCliVerified =
|
const isCliVerified =
|
||||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
|
// Use React Query for usage data
|
||||||
|
const {
|
||||||
|
data: claudeUsage,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
dataUpdatedAt,
|
||||||
|
refetch,
|
||||||
|
} = useClaudeUsage(isCliVerified);
|
||||||
|
|
||||||
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isStale = useMemo(() => {
|
const isStale = useMemo(() => {
|
||||||
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||||
}, [claudeUsageLastUpdated]);
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
const fetchUsage = useCallback(
|
|
||||||
async (isAutoRefresh = false) => {
|
|
||||||
if (!isAutoRefresh) setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.claude) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
|
||||||
message: 'Claude API bridge not available',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.claude.getUsage();
|
|
||||||
if ('error' in data) {
|
|
||||||
// Detect trust prompt error
|
|
||||||
const isTrustPrompt =
|
|
||||||
data.error === 'Trust prompt pending' ||
|
|
||||||
(data.message && data.message.includes('folder permission'));
|
|
||||||
setError({
|
|
||||||
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setClaudeUsage(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.UNKNOWN,
|
|
||||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!isAutoRefresh) setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setClaudeUsage]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-fetch on mount if data is stale (only if CLI is verified)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStale && isCliVerified) {
|
|
||||||
fetchUsage(true);
|
|
||||||
}
|
|
||||||
}, [isStale, isCliVerified, fetchUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip if CLI is not verified
|
|
||||||
if (!isCliVerified) return;
|
|
||||||
|
|
||||||
// Initial fetch when opened
|
|
||||||
if (open) {
|
|
||||||
if (!claudeUsage || isStale) {
|
|
||||||
fetchUsage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh interval (only when open)
|
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
|
||||||
if (open) {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
fetchUsage(true);
|
|
||||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalId) clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -144,7 +69,6 @@ export function ClaudeUsagePopover() {
|
|||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
stale?: boolean;
|
stale?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
// Check if percentage is valid (not NaN, not undefined, is a finite number)
|
|
||||||
const isValidPercentage =
|
const isValidPercentage =
|
||||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||||
const safePercentage = isValidPercentage ? percentage : 0;
|
const safePercentage = isValidPercentage ? percentage : 0;
|
||||||
@@ -245,10 +169,10 @@ export function ClaudeUsagePopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||||
onClick={() => !loading && fetchUsage(false)}
|
onClick={() => !isFetching && refetch()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -259,26 +183,16 @@ export function ClaudeUsagePopover() {
|
|||||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||||
<div className="space-y-1 flex flex-col items-center">
|
<div className="space-y-1 flex flex-col items-center">
|
||||||
<p className="text-sm font-medium">{error.message}</p>
|
<p className="text-sm font-medium">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to fetch usage'}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
|
||||||
'Ensure the Electron bridge is running or restart the app'
|
|
||||||
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
|
|
||||||
<>
|
|
||||||
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
|
|
||||||
terminal and approve access to continue
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Make sure Claude CLI is installed and authenticated via{' '}
|
Make sure Claude CLI is installed and authenticated via{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
<code className="font-mono bg-muted px-1 rounded">claude login</code>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : !claudeUsage ? (
|
) : isLoading || !claudeUsage ? (
|
||||||
// Loading state
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } 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 { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { useCodexUsage } from '@/hooks/queries';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -23,9 +22,6 @@ type UsageError = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fixed refresh interval (45 seconds)
|
|
||||||
const REFRESH_INTERVAL_SECONDS = 45;
|
|
||||||
|
|
||||||
// Helper to format reset time
|
// Helper to format reset time
|
||||||
function formatResetTime(unixTimestamp: number): string {
|
function formatResetTime(unixTimestamp: number): string {
|
||||||
const date = new Date(unixTimestamp * 1000);
|
const date = new Date(unixTimestamp * 1000);
|
||||||
@@ -63,95 +59,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CodexUsagePopover() {
|
export function CodexUsagePopover() {
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<UsageError | null>(null);
|
|
||||||
|
|
||||||
// Check if Codex is authenticated
|
// Check if Codex is authenticated
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
// Use React Query for data fetching with automatic polling
|
||||||
|
const {
|
||||||
|
data: codexUsage,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error: queryError,
|
||||||
|
dataUpdatedAt,
|
||||||
|
refetch,
|
||||||
|
} = useCodexUsage(isCodexAuthenticated);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isStale = useMemo(() => {
|
const isStale = useMemo(() => {
|
||||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
|
||||||
}, [codexUsageLastUpdated]);
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
const fetchUsage = useCallback(
|
// Convert query error to UsageError format for backward compatibility
|
||||||
async (isAutoRefresh = false) => {
|
const error = useMemo((): UsageError | null => {
|
||||||
if (!isAutoRefresh) setLoading(true);
|
if (!queryError) return null;
|
||||||
setError(null);
|
const message = queryError instanceof Error ? queryError.message : String(queryError);
|
||||||
try {
|
if (message.includes('not available') || message.includes('does not provide')) {
|
||||||
const api = getElectronAPI();
|
return { code: ERROR_CODES.NOT_AVAILABLE, message };
|
||||||
if (!api.codex) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
|
||||||
message: 'Codex API bridge not available',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const data = await api.codex.getUsage();
|
if (message.includes('bridge') || message.includes('API')) {
|
||||||
if ('error' in data) {
|
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||||
// Check if it's the "not available" error
|
|
||||||
if (
|
|
||||||
data.message?.includes('not available') ||
|
|
||||||
data.message?.includes('does not provide')
|
|
||||||
) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.NOT_AVAILABLE,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.AUTH_ERROR,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return;
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
}
|
}, [queryError]);
|
||||||
setCodexUsage(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError({
|
|
||||||
code: ERROR_CODES.UNKNOWN,
|
|
||||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!isAutoRefresh) setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setCodexUsage]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-fetch on mount if data is stale (only if authenticated)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStale && isCodexAuthenticated) {
|
|
||||||
fetchUsage(true);
|
|
||||||
}
|
|
||||||
}, [isStale, isCodexAuthenticated, fetchUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip if not authenticated
|
|
||||||
if (!isCodexAuthenticated) return;
|
|
||||||
|
|
||||||
// Initial fetch when opened
|
|
||||||
if (open) {
|
|
||||||
if (!codexUsage || isStale) {
|
|
||||||
fetchUsage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh interval (only when open)
|
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
|
||||||
if (open) {
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
fetchUsage(true);
|
|
||||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalId) clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -289,10 +229,10 @@ export function CodexUsagePopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
className={cn('h-6 w-6', isFetching && 'opacity-80')}
|
||||||
onClick={() => !loading && fetchUsage(false)}
|
onClick={() => !isFetching && refetch()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { useWorkspaceDirectories } from '@/hooks/queries';
|
||||||
|
|
||||||
interface WorkspaceDirectory {
|
interface WorkspaceDirectory {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,41 +23,15 @@ interface WorkspacePickerModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// React Query hook - only fetch when modal is open
|
||||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadDirectories = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = getHttpApiClient();
|
|
||||||
const result = await client.workspace.getDirectories();
|
|
||||||
|
|
||||||
if (result.success && result.directories) {
|
|
||||||
setDirectories(result.directories);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load directories');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load directories when modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
loadDirectories();
|
|
||||||
}
|
|
||||||
}, [open, loadDirectories]);
|
|
||||||
|
|
||||||
const handleSelect = (dir: WorkspaceDirectory) => {
|
const handleSelect = (dir: WorkspaceDirectory) => {
|
||||||
onSelect(dir.path, dir.name);
|
onSelect(dir.path, dir.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
<DialogContent className="bg-card border-border max-w-lg max-h-[80vh] flex flex-col">
|
||||||
@@ -80,19 +53,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !isLoading && (
|
{errorMessage && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||||
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
|
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && directories.length === 0 && (
|
{!isLoading && !errorMessage && directories.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
|
||||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||||
@@ -103,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && directories.length > 0 && (
|
{!isLoading && !errorMessage && directories.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{directories.map((dir) => (
|
{directories.map((dir) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
const logger = createLogger('SessionManager');
|
const logger = createLogger('SessionManager');
|
||||||
@@ -22,6 +23,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { SessionListItem } from '@/types/electron';
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { useSessions } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
||||||
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ export function SessionManager({
|
|||||||
onQuickCreateRef,
|
onQuickCreateRef,
|
||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
||||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
@@ -113,8 +116,14 @@ export function SessionManager({
|
|||||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Use React Query for sessions list - always include archived, filter client-side
|
||||||
|
const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
|
||||||
|
|
||||||
|
// Ref to track if we've done the initial running sessions check
|
||||||
|
const hasCheckedInitialRef = useRef(false);
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.agent) return;
|
if (!api?.agent) return;
|
||||||
|
|
||||||
@@ -134,26 +143,26 @@ export function SessionManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRunningSessions(runningIds);
|
setRunningSessions(runningIds);
|
||||||
};
|
|
||||||
|
|
||||||
// Load sessions
|
|
||||||
const loadSessions = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.sessions) return;
|
|
||||||
|
|
||||||
// Always load all sessions and filter client-side
|
|
||||||
const result = await api.sessions.list(true);
|
|
||||||
if (result.success && result.sessions) {
|
|
||||||
setSessions(result.sessions);
|
|
||||||
// Check running state for all sessions
|
|
||||||
await checkRunningSessions(result.sessions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSessions();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Helper to invalidate sessions cache and refetch
|
||||||
|
const invalidateSessions = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
|
||||||
|
// Also check running state after invalidation
|
||||||
|
const result = await refetchSessions();
|
||||||
|
if (result.data) {
|
||||||
|
await checkRunningSessions(result.data);
|
||||||
|
}
|
||||||
|
}, [queryClient, refetchSessions, checkRunningSessions]);
|
||||||
|
|
||||||
|
// Check running state on initial load (runs only once when sessions first load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessions.length > 0 && !hasCheckedInitialRef.current) {
|
||||||
|
hasCheckedInitialRef.current = true;
|
||||||
|
checkRunningSessions(sessions);
|
||||||
|
}
|
||||||
|
}, [sessions, checkRunningSessions]);
|
||||||
|
|
||||||
// Periodically check running state for sessions (useful for detecting when agents finish)
|
// Periodically check running state for sessions (useful for detecting when agents finish)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only poll if there are running sessions
|
// Only poll if there are running sessions
|
||||||
@@ -166,7 +175,7 @@ export function SessionManager({
|
|||||||
}, 3000); // Check every 3 seconds
|
}, 3000); // Check every 3 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
|
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||||
|
|
||||||
// Create new session with random name
|
// Create new session with random name
|
||||||
const handleCreateSession = async () => {
|
const handleCreateSession = async () => {
|
||||||
@@ -180,7 +189,7 @@ export function SessionManager({
|
|||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName('');
|
setNewSessionName('');
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -195,7 +204,7 @@ export function SessionManager({
|
|||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,7 +231,7 @@ export function SessionManager({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName('');
|
setEditingName('');
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +250,7 @@ export function SessionManager({
|
|||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
onSelectSession(null);
|
onSelectSession(null);
|
||||||
}
|
}
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
} else {
|
} else {
|
||||||
logger.error('[SessionManager] Archive failed:', result.error);
|
logger.error('[SessionManager] Archive failed:', result.error);
|
||||||
}
|
}
|
||||||
@@ -261,7 +270,7 @@ export function SessionManager({
|
|||||||
try {
|
try {
|
||||||
const result = await api.sessions.unarchive(sessionId);
|
const result = await api.sessions.unarchive(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
} else {
|
} else {
|
||||||
logger.error('[SessionManager] Unarchive failed:', result.error);
|
logger.error('[SessionManager] Unarchive failed:', result.error);
|
||||||
}
|
}
|
||||||
@@ -283,7 +292,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
const result = await api.sessions.delete(sessionId);
|
const result = await api.sessions.delete(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
// Switch to another session or create a new one
|
// Switch to another session or create a new one
|
||||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
||||||
@@ -305,7 +314,7 @@ export function SessionManager({
|
|||||||
await api.sessions.delete(session.id);
|
await api.sessions.delete(session.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadSessions();
|
await invalidateSessions();
|
||||||
setIsDeleteAllArchivedDialogOpen(false);
|
setIsDeleteAllArchivedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Button } from './button';
|
import { Button } from './button';
|
||||||
|
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||||
import type { FileStatus } from '@/types/electron';
|
import type { FileStatus } from '@/types/electron';
|
||||||
|
|
||||||
interface GitDiffPanelProps {
|
interface GitDiffPanelProps {
|
||||||
@@ -350,56 +350,44 @@ export function GitDiffPanel({
|
|||||||
useWorktrees = false,
|
useWorktrees = false,
|
||||||
}: GitDiffPanelProps) {
|
}: GitDiffPanelProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
|
||||||
const [diffContent, setDiffContent] = useState<string>('');
|
|
||||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const loadDiffs = useCallback(async () => {
|
// Use worktree diffs hook when worktrees are enabled and panel is expanded
|
||||||
setIsLoading(true);
|
// Pass undefined for featureId when not using worktrees to disable the query
|
||||||
setError(null);
|
const {
|
||||||
try {
|
data: worktreeDiffsData,
|
||||||
const api = getElectronAPI();
|
isLoading: isLoadingWorktree,
|
||||||
|
error: worktreeError,
|
||||||
|
refetch: refetchWorktree,
|
||||||
|
} = useWorktreeDiffs(
|
||||||
|
useWorktrees && isExpanded ? projectPath : undefined,
|
||||||
|
useWorktrees && isExpanded ? featureId : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Use worktree API if worktrees are enabled, otherwise use git API for main project
|
// Use git diffs hook when worktrees are disabled and panel is expanded
|
||||||
if (useWorktrees) {
|
const {
|
||||||
if (!api?.worktree?.getDiffs) {
|
data: gitDiffsData,
|
||||||
throw new Error('Worktree API not available');
|
isLoading: isLoadingGit,
|
||||||
}
|
error: gitError,
|
||||||
const result = await api.worktree.getDiffs(projectPath, featureId);
|
refetch: refetchGit,
|
||||||
if (result.success) {
|
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
|
||||||
setFiles(result.files || []);
|
|
||||||
setDiffContent(result.diff || '');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load diffs');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use git API for main project diffs
|
|
||||||
if (!api?.git?.getDiffs) {
|
|
||||||
throw new Error('Git API not available');
|
|
||||||
}
|
|
||||||
const result = await api.git.getDiffs(projectPath);
|
|
||||||
if (result.success) {
|
|
||||||
setFiles(result.files || []);
|
|
||||||
setDiffContent(result.diff || '');
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to load diffs');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load diffs');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [projectPath, featureId, useWorktrees]);
|
|
||||||
|
|
||||||
// Load diffs when expanded
|
// Select the appropriate data based on useWorktrees prop
|
||||||
useEffect(() => {
|
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
|
||||||
if (isExpanded) {
|
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
||||||
loadDiffs();
|
const queryError = useWorktrees ? worktreeError : gitError;
|
||||||
}
|
|
||||||
}, [isExpanded, loadDiffs]);
|
// Extract files and diff content from the data
|
||||||
|
const files: FileStatus[] = diffsData?.files ?? [];
|
||||||
|
const diffContent = diffsData?.diff ?? '';
|
||||||
|
const error = queryError
|
||||||
|
? queryError instanceof Error
|
||||||
|
? queryError.message
|
||||||
|
: 'Failed to load diffs'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Refetch function
|
||||||
|
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
||||||
|
|
||||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||||
|
|
||||||
|
|||||||
18
apps/ui/src/components/ui/skeleton.tsx
Normal file
18
apps/ui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Skeleton Components
|
||||||
|
*
|
||||||
|
* Loading placeholder components for content that's being fetched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SkeletonPulseProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulsing skeleton placeholder for loading states
|
||||||
|
*/
|
||||||
|
export function SkeletonPulse({ className }: SkeletonPulseProps) {
|
||||||
|
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } 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 { useAppStore } from '@/store/app-store';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -61,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UsagePopover() {
|
export function UsagePopover() {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
||||||
const [claudeLoading, setClaudeLoading] = useState(false);
|
|
||||||
const [codexLoading, setCodexLoading] = useState(false);
|
|
||||||
const [claudeError, setClaudeError] = useState<UsageError | null>(null);
|
|
||||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
// Use React Query hooks for usage data
|
||||||
|
// Only enable polling when popover is open AND the tab is active
|
||||||
|
const {
|
||||||
|
data: claudeUsage,
|
||||||
|
isLoading: claudeLoading,
|
||||||
|
error: claudeQueryError,
|
||||||
|
dataUpdatedAt: claudeUsageLastUpdated,
|
||||||
|
refetch: refetchClaude,
|
||||||
|
} = useClaudeUsage(open && activeTab === 'claude' && isClaudeAuthenticated);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: codexUsage,
|
||||||
|
isLoading: codexLoading,
|
||||||
|
error: codexQueryError,
|
||||||
|
dataUpdatedAt: codexUsageLastUpdated,
|
||||||
|
refetch: refetchCodex,
|
||||||
|
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
|
||||||
|
|
||||||
|
// Parse errors into structured format
|
||||||
|
const claudeError = useMemo((): UsageError | null => {
|
||||||
|
if (!claudeQueryError) return null;
|
||||||
|
const message =
|
||||||
|
claudeQueryError instanceof Error ? claudeQueryError.message : String(claudeQueryError);
|
||||||
|
// Detect trust prompt error
|
||||||
|
const isTrustPrompt = message.includes('Trust prompt') || message.includes('folder permission');
|
||||||
|
if (isTrustPrompt) {
|
||||||
|
return { code: ERROR_CODES.TRUST_PROMPT, message };
|
||||||
|
}
|
||||||
|
if (message.includes('API bridge')) {
|
||||||
|
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||||
|
}
|
||||||
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
|
}, [claudeQueryError]);
|
||||||
|
|
||||||
|
const codexError = useMemo((): UsageError | null => {
|
||||||
|
if (!codexQueryError) return null;
|
||||||
|
const message =
|
||||||
|
codexQueryError instanceof Error ? codexQueryError.message : String(codexQueryError);
|
||||||
|
if (message.includes('not available') || message.includes('does not provide')) {
|
||||||
|
return { code: ERROR_CODES.NOT_AVAILABLE, message };
|
||||||
|
}
|
||||||
|
if (message.includes('API bridge')) {
|
||||||
|
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||||
|
}
|
||||||
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
|
}, [codexQueryError]);
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeAuthenticated) {
|
if (isClaudeAuthenticated) {
|
||||||
@@ -95,137 +135,9 @@ export function UsagePopover() {
|
|||||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
}, [codexUsageLastUpdated]);
|
}, [codexUsageLastUpdated]);
|
||||||
|
|
||||||
const fetchClaudeUsage = useCallback(
|
// Refetch functions for manual refresh
|
||||||
async (isAutoRefresh = false) => {
|
const fetchClaudeUsage = () => refetchClaude();
|
||||||
if (!isAutoRefresh) setClaudeLoading(true);
|
const fetchCodexUsage = () => refetchCodex();
|
||||||
setClaudeError(null);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.claude) {
|
|
||||||
setClaudeError({
|
|
||||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
|
||||||
message: 'Claude API bridge not available',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.claude.getUsage();
|
|
||||||
if ('error' in data) {
|
|
||||||
// Detect trust prompt error
|
|
||||||
const isTrustPrompt =
|
|
||||||
data.error === 'Trust prompt pending' ||
|
|
||||||
(data.message && data.message.includes('folder permission'));
|
|
||||||
setClaudeError({
|
|
||||||
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setClaudeUsage(data);
|
|
||||||
} catch (err) {
|
|
||||||
setClaudeError({
|
|
||||||
code: ERROR_CODES.UNKNOWN,
|
|
||||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!isAutoRefresh) setClaudeLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setClaudeUsage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchCodexUsage = useCallback(
|
|
||||||
async (isAutoRefresh = false) => {
|
|
||||||
if (!isAutoRefresh) setCodexLoading(true);
|
|
||||||
setCodexError(null);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.codex) {
|
|
||||||
setCodexError({
|
|
||||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
|
||||||
message: 'Codex API bridge not available',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.codex.getUsage();
|
|
||||||
if ('error' in data) {
|
|
||||||
if (
|
|
||||||
data.message?.includes('not available') ||
|
|
||||||
data.message?.includes('does not provide')
|
|
||||||
) {
|
|
||||||
setCodexError({
|
|
||||||
code: ERROR_CODES.NOT_AVAILABLE,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCodexError({
|
|
||||||
code: ERROR_CODES.AUTH_ERROR,
|
|
||||||
message: data.message || data.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCodexUsage(data);
|
|
||||||
} catch (err) {
|
|
||||||
setCodexError({
|
|
||||||
code: ERROR_CODES.UNKNOWN,
|
|
||||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (!isAutoRefresh) setCodexLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setCodexUsage]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-fetch on mount if data is stale
|
|
||||||
useEffect(() => {
|
|
||||||
if (isClaudeStale && isClaudeAuthenticated) {
|
|
||||||
fetchClaudeUsage(true);
|
|
||||||
}
|
|
||||||
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isCodexStale && isCodexAuthenticated) {
|
|
||||||
fetchCodexUsage(true);
|
|
||||||
}
|
|
||||||
}, [isCodexStale, isCodexAuthenticated, fetchCodexUsage]);
|
|
||||||
|
|
||||||
// Auto-refresh when popover is open
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
// Fetch based on active tab
|
|
||||||
if (activeTab === 'claude' && isClaudeAuthenticated) {
|
|
||||||
if (!claudeUsage || isClaudeStale) {
|
|
||||||
fetchClaudeUsage();
|
|
||||||
}
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
fetchClaudeUsage(true);
|
|
||||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTab === 'codex' && isCodexAuthenticated) {
|
|
||||||
if (!codexUsage || isCodexStale) {
|
|
||||||
fetchCodexUsage();
|
|
||||||
}
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
fetchCodexUsage(true);
|
|
||||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
open,
|
|
||||||
activeTab,
|
|
||||||
claudeUsage,
|
|
||||||
isClaudeStale,
|
|
||||||
isClaudeAuthenticated,
|
|
||||||
codexUsage,
|
|
||||||
isCodexStale,
|
|
||||||
isCodexAuthenticated,
|
|
||||||
fetchClaudeUsage,
|
|
||||||
fetchCodexUsage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -417,7 +329,7 @@ export function UsagePopover() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
|
||||||
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
|
onClick={() => !claudeLoading && fetchClaudeUsage()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -524,7 +436,7 @@ export function UsagePopover() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
|
||||||
onClick={() => !codexLoading && fetchCodexUsage(false)}
|
onClick={() => !codexLoading && fetchCodexUsage()}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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 { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
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';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +74,7 @@ export function AnalysisView() {
|
|||||||
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
|
||||||
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
const [featureListGenerated, setFeatureListGenerated] = useState(false);
|
||||||
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
const [featureListError, setFeatureListError] = useState<string | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Recursively scan directory
|
// Recursively scan directory
|
||||||
const scanDirectory = useCallback(
|
const scanDirectory = useCallback(
|
||||||
@@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
|
||||||
setFeatureListGenerated(true);
|
setFeatureListGenerated(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to generate feature list:', error);
|
logger.error('Failed to generate feature list:', error);
|
||||||
@@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingFeatureList(false);
|
setIsGeneratingFeatureList(false);
|
||||||
}
|
}
|
||||||
}, [currentProject, projectAnalysis]);
|
}, [currentProject, projectAnalysis, queryClient]);
|
||||||
|
|
||||||
// Toggle folder expansion
|
// Toggle folder expansion
|
||||||
const toggleFolder = (path: string) => {
|
const toggleFolder = (path: string) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
|
DndContext,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
@@ -35,6 +36,7 @@ import { toast } from 'sonner';
|
|||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
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 { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
@@ -48,19 +50,21 @@ import {
|
|||||||
CompletedFeaturesModal,
|
CompletedFeaturesModal,
|
||||||
ArchiveAllVerifiedDialog,
|
ArchiveAllVerifiedDialog,
|
||||||
DeleteCompletedFeatureDialog,
|
DeleteCompletedFeatureDialog,
|
||||||
|
DependencyLinkDialog,
|
||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
FollowUpDialog,
|
FollowUpDialog,
|
||||||
PlanApprovalDialog,
|
PlanApprovalDialog,
|
||||||
|
PullResolveConflictsDialog,
|
||||||
} from './board-view/dialogs';
|
} from './board-view/dialogs';
|
||||||
|
import type { DependencyLinkType } from './board-view/dialogs';
|
||||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||||
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
|
||||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||||
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
|
|
||||||
import { WorktreePanel } from './board-view/worktree-panel';
|
import { WorktreePanel } from './board-view/worktree-panel';
|
||||||
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
|
import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
|
||||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||||
import {
|
import {
|
||||||
useBoardFeatures,
|
useBoardFeatures,
|
||||||
@@ -79,6 +83,10 @@ import { SelectionActionBar, ListView } from './board-view/components';
|
|||||||
import { MassEditDialog } from './board-view/dialogs';
|
import { MassEditDialog } from './board-view/dialogs';
|
||||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { useAutoModeQueryInvalidation } from '@/hooks/use-query-invalidation';
|
||||||
|
|
||||||
// Stable empty array to avoid infinite loop in selector
|
// Stable empty array to avoid infinite loop in selector
|
||||||
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
|
||||||
@@ -108,9 +116,37 @@ export function BoardView() {
|
|||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
setPipelineConfig,
|
setPipelineConfig,
|
||||||
} = useAppStore();
|
} = useAppStore(
|
||||||
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
|
useShallow((state) => ({
|
||||||
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
|
currentProject: state.currentProject,
|
||||||
|
maxConcurrency: state.maxConcurrency,
|
||||||
|
setMaxConcurrency: state.setMaxConcurrency,
|
||||||
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
|
specCreatingForProject: state.specCreatingForProject,
|
||||||
|
setSpecCreatingForProject: state.setSpecCreatingForProject,
|
||||||
|
pendingPlanApproval: state.pendingPlanApproval,
|
||||||
|
setPendingPlanApproval: state.setPendingPlanApproval,
|
||||||
|
updateFeature: state.updateFeature,
|
||||||
|
getCurrentWorktree: state.getCurrentWorktree,
|
||||||
|
setCurrentWorktree: state.setCurrentWorktree,
|
||||||
|
getWorktrees: state.getWorktrees,
|
||||||
|
setWorktrees: state.setWorktrees,
|
||||||
|
useWorktrees: state.useWorktrees,
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
|
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||||
|
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
|
||||||
|
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
|
||||||
|
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
|
||||||
|
getPrimaryWorktreeBranch: state.getPrimaryWorktreeBranch,
|
||||||
|
setPipelineConfig: state.setPipelineConfig,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
// Fetch pipeline config via React Query
|
||||||
|
const { data: pipelineConfig } = usePipelineConfig(currentProject?.path);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Subscribe to auto mode events for React Query cache invalidation
|
||||||
|
useAutoModeQueryInvalidation(currentProject?.path);
|
||||||
// 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
|
||||||
@@ -149,7 +185,7 @@ export function BoardView() {
|
|||||||
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
|
||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
|
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
|
||||||
path: string;
|
path: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
@@ -326,10 +362,22 @@ export function BoardView() {
|
|||||||
fetchBranches();
|
fetchBranches();
|
||||||
}, [currentProject, worktreeRefreshKey]);
|
}, [currentProject, worktreeRefreshKey]);
|
||||||
|
|
||||||
// Custom collision detection that prioritizes columns over cards
|
// Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
|
||||||
const collisionDetectionStrategy = useCallback((args: any) => {
|
const collisionDetectionStrategy = useCallback((args: any) => {
|
||||||
// First, check if pointer is within a column
|
|
||||||
const pointerCollisions = pointerWithin(args);
|
const pointerCollisions = pointerWithin(args);
|
||||||
|
|
||||||
|
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
|
||||||
|
// These need to be detected even if they are inside a column
|
||||||
|
const specificTargetCollisions = pointerCollisions.filter((collision: any) => {
|
||||||
|
const id = String(collision.id);
|
||||||
|
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (specificTargetCollisions.length > 0) {
|
||||||
|
return specificTargetCollisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Columns
|
||||||
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
const columnCollisions = pointerCollisions.filter((collision: any) =>
|
||||||
COLUMNS.some((col) => col.id === collision.id)
|
COLUMNS.some((col) => col.id === collision.id)
|
||||||
);
|
);
|
||||||
@@ -339,7 +387,7 @@ export function BoardView() {
|
|||||||
return columnCollisions;
|
return columnCollisions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, use rectangle intersection for cards
|
// Priority 3: Fallback to rectangle intersection
|
||||||
return rectIntersection(args);
|
return rectIntersection(args);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -797,10 +845,15 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
|
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
|
||||||
const handleResolveConflicts = useCallback(
|
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
|
||||||
async (worktree: WorktreeInfo) => {
|
setSelectedWorktreeForAction(worktree);
|
||||||
const remoteBranch = `origin/${worktree.branch}`;
|
setShowPullResolveConflictsDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler called when user confirms the pull & resolve conflicts dialog
|
||||||
|
const handleConfirmResolveConflicts = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remoteBranch: string) => {
|
||||||
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||||
|
|
||||||
// Create the feature
|
// Create the feature
|
||||||
@@ -840,6 +893,48 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler called when merge fails due to conflicts and user wants to create a feature to resolve them
|
||||||
|
const handleCreateMergeConflictResolutionFeature = useCallback(
|
||||||
|
async (conflictInfo: MergeConflictInfo) => {
|
||||||
|
const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`;
|
||||||
|
|
||||||
|
// Create the feature
|
||||||
|
const featureData = {
|
||||||
|
title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`,
|
||||||
|
category: 'Maintenance',
|
||||||
|
description,
|
||||||
|
images: [],
|
||||||
|
imagePaths: [],
|
||||||
|
skipTests: defaultSkipTests,
|
||||||
|
model: 'opus' as const,
|
||||||
|
thinkingLevel: 'none' as const,
|
||||||
|
branchName: conflictInfo.targetBranch,
|
||||||
|
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
||||||
|
priority: 1, // High priority for conflict resolution
|
||||||
|
planningMode: 'skip' as const,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture existing feature IDs before adding
|
||||||
|
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||||
|
await handleAddFeature(featureData);
|
||||||
|
|
||||||
|
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||||
|
const latestFeatures = useAppStore.getState().features;
|
||||||
|
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||||
|
|
||||||
|
if (newFeature) {
|
||||||
|
await handleStartImplementation(newFeature);
|
||||||
|
} else {
|
||||||
|
logger.error('Could not find newly created feature to start it automatically.');
|
||||||
|
toast.error('Failed to auto-start feature', {
|
||||||
|
description: 'The feature was created but could not be started automatically.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||||
|
);
|
||||||
|
|
||||||
// Handler for "Make" button - creates a feature and immediately starts it
|
// Handler for "Make" button - creates a feature and immediately starts it
|
||||||
const handleAddAndStartFeature = useCallback(
|
const handleAddAndStartFeature = useCallback(
|
||||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||||
@@ -934,7 +1029,13 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use drag and drop hook
|
// Use drag and drop hook
|
||||||
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
|
const {
|
||||||
|
activeFeature,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragEnd,
|
||||||
|
pendingDependencyLink,
|
||||||
|
clearPendingDependencyLink,
|
||||||
|
} = useBoardDragDrop({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
currentProject,
|
currentProject,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
@@ -942,6 +1043,50 @@ export function BoardView() {
|
|||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle dependency link creation
|
||||||
|
const handleCreateDependencyLink = useCallback(
|
||||||
|
async (linkType: DependencyLinkType) => {
|
||||||
|
if (!pendingDependencyLink || !currentProject) return;
|
||||||
|
|
||||||
|
const { draggedFeature, targetFeature } = pendingDependencyLink;
|
||||||
|
|
||||||
|
if (linkType === 'parent') {
|
||||||
|
// Dragged feature depends on target (target is parent)
|
||||||
|
// Add targetFeature.id to draggedFeature.dependencies
|
||||||
|
const currentDeps = draggedFeature.dependencies || [];
|
||||||
|
if (!currentDeps.includes(targetFeature.id)) {
|
||||||
|
const newDeps = [...currentDeps, targetFeature.id];
|
||||||
|
updateFeature(draggedFeature.id, { dependencies: newDeps });
|
||||||
|
await persistFeatureUpdate(draggedFeature.id, { dependencies: newDeps });
|
||||||
|
toast.success('Dependency link created', {
|
||||||
|
description: `"${draggedFeature.description.slice(0, 30)}..." now depends on "${targetFeature.description.slice(0, 30)}..."`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Target feature depends on dragged (dragged is parent)
|
||||||
|
// Add draggedFeature.id to targetFeature.dependencies
|
||||||
|
const currentDeps = targetFeature.dependencies || [];
|
||||||
|
if (!currentDeps.includes(draggedFeature.id)) {
|
||||||
|
const newDeps = [...currentDeps, draggedFeature.id];
|
||||||
|
updateFeature(targetFeature.id, { dependencies: newDeps });
|
||||||
|
await persistFeatureUpdate(targetFeature.id, { dependencies: newDeps });
|
||||||
|
toast.success('Dependency link created', {
|
||||||
|
description: `"${targetFeature.description.slice(0, 30)}..." now depends on "${draggedFeature.description.slice(0, 30)}..."`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPendingDependencyLink();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
pendingDependencyLink,
|
||||||
|
currentProject,
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
clearPendingDependencyLink,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// Use column features hook
|
// Use column features hook
|
||||||
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
|
||||||
features: hookFeatures,
|
features: hookFeatures,
|
||||||
@@ -953,9 +1098,7 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Build columnFeaturesMap for ListView
|
// Build columnFeaturesMap for ListView
|
||||||
const pipelineConfig = currentProject?.path
|
// pipelineConfig is now from usePipelineConfig React Query hook at the top
|
||||||
? pipelineConfigByProject[currentProject.path] || null
|
|
||||||
: null;
|
|
||||||
const columnFeaturesMap = useMemo(() => {
|
const columnFeaturesMap = useMemo(() => {
|
||||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||||
const map: Record<string, typeof hookFeatures> = {};
|
const map: Record<string, typeof hookFeatures> = {};
|
||||||
@@ -1174,6 +1317,13 @@ export function BoardView() {
|
|||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={collisionDetectionStrategy}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
{/* Worktree Panel - conditionally rendered based on visibility setting */}
|
||||||
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
|
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
|
||||||
<WorktreePanel
|
<WorktreePanel
|
||||||
@@ -1198,9 +1348,20 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
onResolveConflicts={handleResolveConflicts}
|
onResolveConflicts={handleResolveConflicts}
|
||||||
onMerge={(worktree) => {
|
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||||
setSelectedWorktreeForAction(worktree);
|
onBranchDeletedDuringMerge={(branchName) => {
|
||||||
setShowMergeWorktreeDialog(true);
|
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
||||||
|
hookFeatures.forEach((feature) => {
|
||||||
|
if (feature.branchName === branchName) {
|
||||||
|
// Reset the feature's branch assignment - update both local state and persist
|
||||||
|
const updates = {
|
||||||
|
branchName: null as unknown as string | undefined,
|
||||||
|
};
|
||||||
|
updateFeature(feature.id, updates);
|
||||||
|
persistFeatureUpdate(feature.id, updates);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setWorktreeRefreshKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
onRemovedWorktrees={handleRemovedWorktrees}
|
onRemovedWorktrees={handleRemovedWorktrees}
|
||||||
runningFeatureIds={runningAutoTasks}
|
runningFeatureIds={runningAutoTasks}
|
||||||
@@ -1256,10 +1417,6 @@ export function BoardView() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
sensors={sensors}
|
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
activeFeature={activeFeature}
|
activeFeature={activeFeature}
|
||||||
getColumnFeatures={getColumnFeatures}
|
getColumnFeatures={getColumnFeatures}
|
||||||
backgroundImageStyle={backgroundImageStyle}
|
backgroundImageStyle={backgroundImageStyle}
|
||||||
@@ -1301,6 +1458,7 @@ export function BoardView() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{/* Selection Action Bar */}
|
{/* Selection Action Bar */}
|
||||||
{isSelectionMode && (
|
{isSelectionMode && (
|
||||||
@@ -1394,6 +1552,15 @@ export function BoardView() {
|
|||||||
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Dependency Link Dialog */}
|
||||||
|
<DependencyLinkDialog
|
||||||
|
open={Boolean(pendingDependencyLink)}
|
||||||
|
onOpenChange={(open) => !open && clearPendingDependencyLink()}
|
||||||
|
draggedFeature={pendingDependencyLink?.draggedFeature || null}
|
||||||
|
targetFeature={pendingDependencyLink?.targetFeature || null}
|
||||||
|
onLink={handleCreateDependencyLink}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Edit Feature Dialog */}
|
{/* Edit Feature Dialog */}
|
||||||
<EditFeatureDialog
|
<EditFeatureDialog
|
||||||
feature={editingFeature}
|
feature={editingFeature}
|
||||||
@@ -1441,6 +1608,11 @@ export function BoardView() {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to save pipeline config');
|
throw new Error(result.error || 'Failed to save pipeline config');
|
||||||
}
|
}
|
||||||
|
// Invalidate React Query cache to refetch updated config
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.pipeline.config(currentProject.path),
|
||||||
|
});
|
||||||
|
// Also update Zustand for backward compatibility
|
||||||
setPipelineConfig(currentProject.path, config);
|
setPipelineConfig(currentProject.path, config);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1560,33 +1732,12 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Merge Worktree Dialog */}
|
{/* Pull & Resolve Conflicts Dialog */}
|
||||||
<MergeWorktreeDialog
|
<PullResolveConflictsDialog
|
||||||
open={showMergeWorktreeDialog}
|
open={showPullResolveConflictsDialog}
|
||||||
onOpenChange={setShowMergeWorktreeDialog}
|
onOpenChange={setShowPullResolveConflictsDialog}
|
||||||
projectPath={currentProject.path}
|
|
||||||
worktree={selectedWorktreeForAction}
|
worktree={selectedWorktreeForAction}
|
||||||
affectedFeatureCount={
|
onConfirm={handleConfirmResolveConflicts}
|
||||||
selectedWorktreeForAction
|
|
||||||
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
onMerged={(mergedWorktree) => {
|
|
||||||
// Reset features that were assigned to the merged worktree (by branch)
|
|
||||||
hookFeatures.forEach((feature) => {
|
|
||||||
if (feature.branchName === mergedWorktree.branch) {
|
|
||||||
// Reset the feature's branch assignment - update both local state and persist
|
|
||||||
const updates = {
|
|
||||||
branchName: null as unknown as string | undefined,
|
|
||||||
};
|
|
||||||
updateFeature(feature.id, updates);
|
|
||||||
persistFeatureUpdate(feature.id, updates);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setWorktreeRefreshKey((k) => k + 1);
|
|
||||||
setSelectedWorktreeForAction(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Commit Worktree Dialog */}
|
{/* Commit Worktree Dialog */}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// @ts-nocheck
|
import { memo, useEffect, useState, useMemo } from 'react';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
|
||||||
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
@@ -16,6 +15,7 @@ import { Spinner } from '@/components/ui/spinner';
|
|||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
|
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
@@ -50,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
|
|||||||
|
|
||||||
interface AgentInfoPanelProps {
|
interface AgentInfoPanelProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
|
projectPath: string;
|
||||||
contextContent?: string;
|
contextContent?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
isCurrentAutoTask?: boolean;
|
isCurrentAutoTask?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentInfoPanel({
|
export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||||
feature,
|
feature,
|
||||||
|
projectPath,
|
||||||
contextContent,
|
contextContent,
|
||||||
summary,
|
summary,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
// Track real-time task status updates from WebSocket events
|
// Track real-time task status updates from WebSocket events
|
||||||
const [taskStatusMap, setTaskStatusMap] = useState<
|
const [taskStatusMap, setTaskStatusMap] = useState<
|
||||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
// Fresh planSpec data fetched from API (store data is stale for task progress)
|
|
||||||
const [freshPlanSpec, setFreshPlanSpec] = useState<{
|
// Determine if we should poll for updates
|
||||||
tasks?: ParsedTask[];
|
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
|
||||||
tasksCompleted?: number;
|
const shouldFetchData = feature.status !== 'backlog';
|
||||||
currentTaskId?: string;
|
|
||||||
} | null>(null);
|
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
|
||||||
|
const { data: freshFeature } = useFeature(projectPath, feature.id, {
|
||||||
|
enabled: shouldFetchData && !contextContent,
|
||||||
|
pollingInterval: shouldPoll ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch agent output for parsing
|
||||||
|
const { data: agentOutputContent } = useAgentOutput(projectPath, feature.id, {
|
||||||
|
enabled: shouldFetchData && !contextContent,
|
||||||
|
pollingInterval: shouldPoll ? 3000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse agent output into agentInfo
|
||||||
|
const agentInfo = useMemo(() => {
|
||||||
|
if (contextContent) {
|
||||||
|
return parseAgentContext(contextContent);
|
||||||
|
}
|
||||||
|
if (agentOutputContent) {
|
||||||
|
return parseAgentContext(agentOutputContent);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [contextContent, agentOutputContent]);
|
||||||
|
|
||||||
|
// Fresh planSpec data from API (more accurate than store data for task progress)
|
||||||
|
const freshPlanSpec = useMemo(() => {
|
||||||
|
if (!freshFeature?.planSpec) return null;
|
||||||
|
return {
|
||||||
|
tasks: freshFeature.planSpec.tasks,
|
||||||
|
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
|
||||||
|
currentTaskId: freshFeature.planSpec.currentTaskId,
|
||||||
|
};
|
||||||
|
}, [freshFeature?.planSpec]);
|
||||||
|
|
||||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||||
@@ -125,73 +157,6 @@ export function AgentInfoPanel({
|
|||||||
taskStatusMap,
|
taskStatusMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadContext = async () => {
|
|
||||||
if (contextContent) {
|
|
||||||
const info = parseAgentContext(contextContent);
|
|
||||||
setAgentInfo(info);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feature.status === 'backlog') {
|
|
||||||
setAgentInfo(null);
|
|
||||||
setFreshPlanSpec(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const currentProject = (window as any).__currentProject;
|
|
||||||
if (!currentProject?.path) return;
|
|
||||||
|
|
||||||
if (api.features) {
|
|
||||||
// Fetch fresh feature data to get up-to-date planSpec (store data is stale)
|
|
||||||
try {
|
|
||||||
const featureResult = await api.features.get(currentProject.path, feature.id);
|
|
||||||
const freshFeature: any = (featureResult as any).feature;
|
|
||||||
if (featureResult.success && freshFeature?.planSpec) {
|
|
||||||
setFreshPlanSpec({
|
|
||||||
tasks: freshFeature.planSpec.tasks,
|
|
||||||
tasksCompleted: freshFeature.planSpec.tasksCompleted || 0,
|
|
||||||
currentTaskId: freshFeature.planSpec.currentTaskId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors fetching fresh planSpec
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
|
|
||||||
|
|
||||||
if (result.success && result.content) {
|
|
||||||
const info = parseAgentContext(result.content);
|
|
||||||
setAgentInfo(info);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
|
||||||
const result = await api.readFile(contextPath);
|
|
||||||
|
|
||||||
if (result.success && result.content) {
|
|
||||||
const info = parseAgentContext(result.content);
|
|
||||||
setAgentInfo(info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.debug('[KanbanCard] No context file for feature:', feature.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadContext();
|
|
||||||
|
|
||||||
// Poll for updates when feature is in_progress (not just isCurrentAutoTask)
|
|
||||||
// This ensures planSpec progress stays in sync
|
|
||||||
if (isCurrentAutoTask || feature.status === 'in_progress') {
|
|
||||||
const interval = setInterval(loadContext, 3000);
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
|
||||||
|
|
||||||
// Listen to WebSocket events for real-time task status updates
|
// Listen to WebSocket events for real-time task status updates
|
||||||
// This ensures the Kanban card shows the same progress as the Agent Output modal
|
// This ensures the Kanban card shows the same progress as the Agent Output modal
|
||||||
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
|
// Listen for ANY in-progress feature with planSpec tasks, not just isCurrentAutoTask
|
||||||
@@ -440,4 +405,4 @@ export function AgentInfoPanel({
|
|||||||
onOpenChange={setIsSummaryDialogOpen}
|
onOpenChange={setIsSummaryDialogOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
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';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ interface CardActionsProps {
|
|||||||
onApprovePlan?: () => void;
|
onApprovePlan?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardActions({
|
export const CardActions = memo(function CardActions({
|
||||||
feature,
|
feature,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
hasContext,
|
hasContext,
|
||||||
@@ -344,4 +345,4 @@ export function CardActions({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { 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, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -18,7 +19,7 @@ interface CardBadgesProps {
|
|||||||
* CardBadges - Shows error badges below the card header
|
* CardBadges - Shows error badges below the card header
|
||||||
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
|
||||||
*/
|
*/
|
||||||
export function CardBadges({ feature }: CardBadgesProps) {
|
export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps) {
|
||||||
if (!feature.error) {
|
if (!feature.error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||||
const { enableDependencyBlocking, features } = useAppStore();
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
|
features: state.features,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
|
|||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
|
export const CardContentSections = memo(function CardContentSections({
|
||||||
|
feature,
|
||||||
|
useWorktrees,
|
||||||
|
}: CardContentSectionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target Branch Display */}
|
{/* Target Branch Display */}
|
||||||
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
|
|||||||
})()}
|
})()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { 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';
|
||||||
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -37,7 +37,7 @@ interface CardHeaderProps {
|
|||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export const CardHeaderSection = memo(function CardHeaderSection({
|
||||||
feature,
|
feature,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isCurrentAutoTask,
|
isCurrentAutoTask,
|
||||||
@@ -378,4 +378,4 @@ export function CardHeaderSection({
|
|||||||
/>
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React, { memo, useLayoutEffect, useState } from 'react';
|
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { CardBadges, PriorityBadges } from './card-badges';
|
import { CardBadges, PriorityBadges } from './card-badges';
|
||||||
import { CardHeaderSection } from './card-header';
|
import { CardHeaderSection } from './card-header';
|
||||||
import { CardContentSections } from './card-content-sections';
|
import { CardContentSections } from './card-content-sections';
|
||||||
@@ -61,6 +62,7 @@ interface KanbanCardProps {
|
|||||||
cardBorderEnabled?: boolean;
|
cardBorderEnabled?: boolean;
|
||||||
cardBorderOpacity?: number;
|
cardBorderOpacity?: number;
|
||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
|
reduceEffects?: boolean;
|
||||||
// Selection mode props
|
// Selection mode props
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
cardBorderEnabled = true,
|
cardBorderEnabled = true,
|
||||||
cardBorderOpacity = 100,
|
cardBorderOpacity = 100,
|
||||||
isOverlay,
|
isOverlay,
|
||||||
|
reduceEffects = false,
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
selectionTarget = null,
|
selectionTarget = null,
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees } = useAppStore();
|
const { useWorktrees, currentProject } = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
useWorktrees: state.useWorktrees,
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const [isLifted, setIsLifted] = useState(false);
|
const [isLifted, setIsLifted] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -115,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
(feature.status === 'backlog' ||
|
(feature.status === 'backlog' ||
|
||||||
feature.status === 'waiting_approval' ||
|
feature.status === 'waiting_approval' ||
|
||||||
feature.status === 'verified' ||
|
feature.status === 'verified' ||
|
||||||
|
feature.status.startsWith('pipeline_') ||
|
||||||
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
(feature.status === 'in_progress' && !isCurrentAutoTask));
|
||||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef: setDraggableRef,
|
||||||
|
isDragging,
|
||||||
|
} = useDraggable({
|
||||||
id: feature.id,
|
id: feature.id,
|
||||||
disabled: !isDraggable || isOverlay || isSelectionMode,
|
disabled: !isDraggable || isOverlay || isSelectionMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make the card a drop target for creating dependency links
|
||||||
|
// Only backlog cards can be link targets (to avoid complexity with running features)
|
||||||
|
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
||||||
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
|
id: `card-drop-${feature.id}`,
|
||||||
|
disabled: !isDroppable,
|
||||||
|
data: {
|
||||||
|
type: 'card',
|
||||||
|
featureId: feature.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine refs for both draggable and droppable
|
||||||
|
const setNodeRef = useCallback(
|
||||||
|
(node: HTMLElement | null) => {
|
||||||
|
setDraggableRef(node);
|
||||||
|
setDroppableRef(node);
|
||||||
|
},
|
||||||
|
[setDraggableRef, setDroppableRef]
|
||||||
|
);
|
||||||
|
|
||||||
const dndStyle = {
|
const dndStyle = {
|
||||||
opacity: isDragging ? 0.5 : undefined,
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
};
|
};
|
||||||
@@ -133,16 +168,21 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const wrapperClasses = cn(
|
const wrapperClasses = cn(
|
||||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||||
getCursorClass(isOverlay, isDraggable, isSelectable),
|
getCursorClass(isOverlay, isDraggable, isSelectable),
|
||||||
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
|
isOverlay && isLifted && 'scale-105 rotate-1 z-50',
|
||||||
|
// Visual feedback when another card is being dragged over this one
|
||||||
|
isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInteractive = !isDragging && !isOverlay;
|
const isInteractive = !isDragging && !isOverlay;
|
||||||
const hasError = feature.error && !isCurrentAutoTask;
|
const hasError = feature.error && !isCurrentAutoTask;
|
||||||
|
|
||||||
const innerCardClasses = cn(
|
const innerCardClasses = cn(
|
||||||
'kanban-card-content h-full relative shadow-sm',
|
'kanban-card-content h-full relative',
|
||||||
|
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
isInteractive &&
|
||||||
|
!reduceEffects &&
|
||||||
|
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
cardBorderEnabled &&
|
cardBorderEnabled &&
|
||||||
@@ -215,6 +255,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{/* Agent Info Panel */}
|
{/* Agent Info Panel */}
|
||||||
<AgentInfoPanel
|
<AgentInfoPanel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
|
projectPath={currentProject?.path ?? ''}
|
||||||
contextContent={contextContent}
|
contextContent={contextContent}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
isCurrentAutoTask={isCurrentAutoTask}
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ReactNode } from 'react';
|
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
|
|||||||
hideScrollbar?: boolean;
|
hideScrollbar?: boolean;
|
||||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
||||||
width?: number;
|
width?: number;
|
||||||
|
contentRef?: Ref<HTMLDivElement>;
|
||||||
|
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
|
||||||
|
contentClassName?: string;
|
||||||
|
contentStyle?: CSSProperties;
|
||||||
|
disableItemSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
showBorder = true,
|
showBorder = true,
|
||||||
hideScrollbar = false,
|
hideScrollbar = false,
|
||||||
width,
|
width,
|
||||||
|
contentRef,
|
||||||
|
onScroll,
|
||||||
|
contentClassName,
|
||||||
|
contentStyle,
|
||||||
|
disableItemSpacing = false,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
|
'relative z-10 flex-1 overflow-y-auto p-2',
|
||||||
|
!disableItemSpacing && 'space-y-2.5',
|
||||||
hideScrollbar &&
|
hideScrollbar &&
|
||||||
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
|
||||||
// Smooth scrolling
|
// Smooth scrolling
|
||||||
'scroll-smooth',
|
'scroll-smooth',
|
||||||
// Add padding at bottom if there's a footer action
|
// Add padding at bottom if there's a footer action
|
||||||
footerAction && 'pb-14'
|
footerAction && 'pb-14',
|
||||||
|
contentClassName
|
||||||
)}
|
)}
|
||||||
|
ref={contentRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
style={contentStyle}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ interface ColumnDef {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Default column definitions for the list view
|
* Default column definitions for the list view
|
||||||
* Only showing title column with full width for a cleaner, more spacious layout
|
|
||||||
*/
|
*/
|
||||||
export const LIST_COLUMNS: ColumnDef[] = [
|
export const LIST_COLUMNS: ColumnDef[] = [
|
||||||
{
|
{
|
||||||
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
|
|||||||
minWidth: 'min-w-0',
|
minWidth: 'min-w-0',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: '',
|
||||||
|
sortable: true,
|
||||||
|
width: 'w-18',
|
||||||
|
minWidth: 'min-w-[16px]',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ListHeaderProps {
|
export interface ListHeaderProps {
|
||||||
@@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
|
|||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||||
column.width,
|
column.width,
|
||||||
column.minWidth,
|
column.minWidth,
|
||||||
|
column.width !== 'flex-1' && 'shrink-0',
|
||||||
column.align === 'center' && 'justify-center',
|
column.align === 'center' && 'justify-center',
|
||||||
column.align === 'right' && 'justify-end',
|
column.align === 'right' && 'justify-end',
|
||||||
isSorted && 'text-foreground',
|
isSorted && 'text-foreground',
|
||||||
@@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
|
|||||||
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||||
column.width,
|
column.width,
|
||||||
column.minWidth,
|
column.minWidth,
|
||||||
|
column.width !== 'flex-1' && 'shrink-0',
|
||||||
column.align === 'center' && 'justify-center',
|
column.align === 'center' && 'justify-center',
|
||||||
column.align === 'right' && 'justify-end',
|
column.align === 'right' && 'justify-end',
|
||||||
column.className
|
column.className
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
|
|||||||
<div
|
<div
|
||||||
role="cell"
|
role="cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center px-3 py-3 gap-2',
|
'flex items-center pl-3 pr-0 py-3 gap-0',
|
||||||
getColumnWidth('title'),
|
getColumnWidth('title'),
|
||||||
getColumnAlign('title')
|
getColumnAlign('title')
|
||||||
)}
|
)}
|
||||||
@@ -315,6 +315,42 @@ export const ListRow = memo(function ListRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Priority column */}
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
||||||
|
getColumnWidth('priority'),
|
||||||
|
getColumnAlign('priority')
|
||||||
|
)}
|
||||||
|
data-testid={`list-row-priority-${feature.id}`}
|
||||||
|
>
|
||||||
|
{feature.priority ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px] font-bold text-xs',
|
||||||
|
feature.priority === 1 &&
|
||||||
|
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
|
||||||
|
feature.priority === 2 &&
|
||||||
|
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
|
||||||
|
feature.priority === 3 &&
|
||||||
|
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
feature.priority === 1
|
||||||
|
? 'High Priority'
|
||||||
|
: feature.priority === 2
|
||||||
|
? 'Medium Priority'
|
||||||
|
: 'Low Priority'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions column */}
|
{/* Actions column */}
|
||||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
|||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { extractSummary } from '@/lib/log-parser';
|
import { extractSummary } from '@/lib/log-parser';
|
||||||
|
import { useAgentOutput } from '@/hooks/queries';
|
||||||
import type { AutoModeEvent } from '@/types/electron';
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
|
||||||
interface AgentOutputModalProps {
|
interface AgentOutputModalProps {
|
||||||
@@ -45,10 +46,30 @@ export function AgentOutputModal({
|
|||||||
branchName,
|
branchName,
|
||||||
}: AgentOutputModalProps) {
|
}: AgentOutputModalProps) {
|
||||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||||
const [output, setOutput] = useState<string>('');
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||||
|
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
|
||||||
|
|
||||||
|
// Track additional content from WebSocket events (appended to query data)
|
||||||
|
const [streamedContent, setStreamedContent] = useState<string>('');
|
||||||
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||||
const [projectPath, setProjectPath] = useState<string>('');
|
|
||||||
|
// Use React Query for initial output loading
|
||||||
|
const { data: initialOutput = '', isLoading } = useAgentOutput(
|
||||||
|
resolvedProjectPath,
|
||||||
|
featureId,
|
||||||
|
open && !!resolvedProjectPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset streamed content when modal opens or featureId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setStreamedContent('');
|
||||||
|
}
|
||||||
|
}, [open, featureId]);
|
||||||
|
|
||||||
|
// Combine initial output from query with streamed content from WebSocket
|
||||||
|
const output = initialOutput + streamedContent;
|
||||||
|
|
||||||
// Extract summary from output
|
// Extract summary from output
|
||||||
const summary = useMemo(() => extractSummary(output), [output]);
|
const summary = useMemo(() => extractSummary(output), [output]);
|
||||||
@@ -57,7 +78,6 @@ export function AgentOutputModal({
|
|||||||
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
const projectPathRef = useRef<string>('');
|
|
||||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
// Auto-scroll to bottom when output changes
|
||||||
@@ -67,55 +87,6 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
}, [output]);
|
}, [output]);
|
||||||
|
|
||||||
// Load existing output from file
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
const loadOutput = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
|
|
||||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
|
|
||||||
if (!resolvedProjectPath) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
projectPathRef.current = resolvedProjectPath;
|
|
||||||
setProjectPath(resolvedProjectPath);
|
|
||||||
|
|
||||||
if (isBacklogPlan) {
|
|
||||||
setOutput('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use features API to get agent output
|
|
||||||
if (api.features) {
|
|
||||||
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setOutput(result.content || '');
|
|
||||||
} else {
|
|
||||||
setOutput('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setOutput('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load output:', error);
|
|
||||||
setOutput('');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadOutput();
|
|
||||||
}, [open, featureId, projectPathProp, isBacklogPlan]);
|
|
||||||
|
|
||||||
// Listen to auto mode events and update output
|
// Listen to auto mode events and update output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -274,8 +245,8 @@ export function AgentOutputModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
// Only update local state - server is the single source of truth for file writes
|
// Append new content from WebSocket to streamed content
|
||||||
setOutput((prev) => prev + newContent);
|
setStreamedContent((prev) => prev + newContent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,16 +397,16 @@ export function AgentOutputModal({
|
|||||||
{!isBacklogPlan && (
|
{!isBacklogPlan && (
|
||||||
<TaskProgressPanel
|
<TaskProgressPanel
|
||||||
featureId={featureId}
|
featureId={featureId}
|
||||||
projectPath={projectPath}
|
projectPath={resolvedProjectPath}
|
||||||
className="shrink-0 mx-3 my-2"
|
className="shrink-0 mx-3 my-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveViewMode === 'changes' ? (
|
{effectiveViewMode === 'changes' ? (
|
||||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{resolvedProjectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
projectPath={projectPath}
|
projectPath={resolvedProjectPath}
|
||||||
featureId={branchName || featureId}
|
featureId={branchName || featureId}
|
||||||
compact={false}
|
compact={false}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +17,7 @@ import { GitPullRequest, ExternalLink } 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 { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useWorktreeBranches } from '@/hooks/queries';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
interface WorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -54,12 +55,21 @@ export function CreatePRDialog({
|
|||||||
const [prUrl, setPrUrl] = useState<string | null>(null);
|
const [prUrl, setPrUrl] = useState<string | null>(null);
|
||||||
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
|
||||||
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
|
||||||
// Branch fetching state
|
|
||||||
const [branches, setBranches] = useState<string[]>([]);
|
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
|
||||||
// Track whether an operation completed that warrants a refresh
|
// Track whether an operation completed that warrants a refresh
|
||||||
const operationCompletedRef = useRef(false);
|
const operationCompletedRef = useRef(false);
|
||||||
|
|
||||||
|
// Use React Query for branch fetching - only enabled when dialog is open
|
||||||
|
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
|
||||||
|
open ? worktree?.path : undefined,
|
||||||
|
true // Include remote branches for PR base branch selection
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out current worktree branch from the list
|
||||||
|
const branches = useMemo(() => {
|
||||||
|
if (!branchesData?.branches) return [];
|
||||||
|
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
|
||||||
|
}, [branchesData?.branches, worktree?.branch]);
|
||||||
|
|
||||||
// Common state reset function to avoid duplication
|
// Common state reset function to avoid duplication
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -72,44 +82,13 @@ export function CreatePRDialog({
|
|||||||
setBrowserUrl(null);
|
setBrowserUrl(null);
|
||||||
setShowBrowserFallback(false);
|
setShowBrowserFallback(false);
|
||||||
operationCompletedRef.current = false;
|
operationCompletedRef.current = false;
|
||||||
setBranches([]);
|
|
||||||
}, [defaultBaseBranch]);
|
}, [defaultBaseBranch]);
|
||||||
|
|
||||||
// Fetch branches for autocomplete
|
|
||||||
const fetchBranches = useCallback(async () => {
|
|
||||||
if (!worktree?.path) return;
|
|
||||||
|
|
||||||
setIsLoadingBranches(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.listBranches) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fetch both local and remote branches for PR base branch selection
|
|
||||||
const result = await api.worktree.listBranches(worktree.path, true);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
// Extract branch names, filtering out the current worktree branch
|
|
||||||
const branchNames = result.result.branches
|
|
||||||
.map((b) => b.name)
|
|
||||||
.filter((name) => name !== worktree.branch);
|
|
||||||
setBranches(branchNames);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently fail - branches will default to main only
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBranches(false);
|
|
||||||
}
|
|
||||||
}, [worktree?.path, worktree?.branch]);
|
|
||||||
|
|
||||||
// Reset state when dialog opens or worktree changes
|
// Reset state when dialog opens or worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset all state on both open and close
|
// Reset all state on both open and close
|
||||||
resetState();
|
resetState();
|
||||||
if (open) {
|
}, [open, worktree?.path, resetState]);
|
||||||
// Fetch fresh branches when dialog opens
|
|
||||||
fetchBranches();
|
|
||||||
}
|
|
||||||
}, [open, worktree?.path, resetState, fetchBranches]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type DependencyLinkType = 'parent' | 'child';
|
||||||
|
|
||||||
|
interface DependencyLinkDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
draggedFeature: Feature | null;
|
||||||
|
targetFeature: Feature | null;
|
||||||
|
onLink: (linkType: DependencyLinkType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyLinkDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
draggedFeature,
|
||||||
|
targetFeature,
|
||||||
|
onLink,
|
||||||
|
}: DependencyLinkDialogProps) {
|
||||||
|
if (!draggedFeature || !targetFeature) return null;
|
||||||
|
|
||||||
|
// Check if a dependency relationship already exists
|
||||||
|
const draggedDependsOnTarget =
|
||||||
|
Array.isArray(draggedFeature.dependencies) &&
|
||||||
|
draggedFeature.dependencies.includes(targetFeature.id);
|
||||||
|
const targetDependsOnDragged =
|
||||||
|
Array.isArray(targetFeature.dependencies) &&
|
||||||
|
targetFeature.dependencies.includes(draggedFeature.id);
|
||||||
|
const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="dependency-link-dialog" className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5" />
|
||||||
|
Link Features
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a dependency relationship between these features.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
{/* Dragged feature */}
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
|
||||||
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
|
{draggedFeature.description}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70 mt-1">{draggedFeature.category}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow indicating direction */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ArrowDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target feature */}
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
|
||||||
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
|
{targetFeature.description}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70 mt-1">{targetFeature.category}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing link warning */}
|
||||||
|
{existingLink && (
|
||||||
|
<div className="p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10 text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
|
{draggedDependsOnTarget
|
||||||
|
? 'The dragged feature already depends on the target feature.'
|
||||||
|
: 'The target feature already depends on the dragged feature.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:!justify-start">
|
||||||
|
{/* Set as Parent - top */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => onLink('child')}
|
||||||
|
disabled={draggedDependsOnTarget}
|
||||||
|
className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
|
||||||
|
title={
|
||||||
|
draggedDependsOnTarget
|
||||||
|
? 'This would create a circular dependency'
|
||||||
|
: 'Make target feature depend on dragged (dragged is parent)'
|
||||||
|
}
|
||||||
|
data-testid="link-as-parent"
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4 mr-2" />
|
||||||
|
Set as Parent
|
||||||
|
<span className="text-xs ml-1 opacity-70">(target depends on this)</span>
|
||||||
|
</Button>
|
||||||
|
{/* Set as Child - middle */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => onLink('parent')}
|
||||||
|
disabled={targetDependsOnDragged}
|
||||||
|
className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
|
||||||
|
title={
|
||||||
|
targetDependsOnDragged
|
||||||
|
? 'This would create a circular dependency'
|
||||||
|
: 'Make dragged feature depend on target (target is parent)'
|
||||||
|
}
|
||||||
|
data-testid="link-as-child"
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-4 h-4 mr-2" />
|
||||||
|
Set as Child
|
||||||
|
<span className="text-xs ml-1 opacity-70">(depends on target)</span>
|
||||||
|
</Button>
|
||||||
|
{/* Cancel - bottom */}
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full">
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
|
|||||||
export { CompletedFeaturesModal } from './completed-features-modal';
|
export { CompletedFeaturesModal } from './completed-features-modal';
|
||||||
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
|
||||||
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
|
||||||
|
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
|
||||||
export { EditFeatureDialog } from './edit-feature-dialog';
|
export { EditFeatureDialog } from './edit-feature-dialog';
|
||||||
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
|
||||||
|
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
|
||||||
export { PlanApprovalDialog } from './plan-approval-dialog';
|
export { PlanApprovalDialog } from './plan-approval-dialog';
|
||||||
export { MassEditDialog } from './mass-edit-dialog';
|
export { MassEditDialog } from './mass-edit-dialog';
|
||||||
|
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
|
||||||
|
export { PushToRemoteDialog } from './push-to-remote-dialog';
|
||||||
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
|
||||||
|
|||||||
@@ -8,58 +8,81 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
import { GitMerge, AlertTriangle, Trash2, Wrench } 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 { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||||
|
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
|
||||||
|
|
||||||
interface WorktreeInfo {
|
export type { MergeConflictInfo } from '../worktree-panel/types';
|
||||||
path: string;
|
|
||||||
branch: string;
|
|
||||||
isMain: boolean;
|
|
||||||
hasChanges?: boolean;
|
|
||||||
changedFilesCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MergeWorktreeDialogProps {
|
interface MergeWorktreeDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
worktree: WorktreeInfo | null;
|
worktree: WorktreeInfo | null;
|
||||||
onMerged: (mergedWorktree: WorktreeInfo) => void;
|
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
|
||||||
/** Number of features assigned to this worktree's branch */
|
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
|
||||||
affectedFeatureCount?: number;
|
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogStep = 'confirm' | 'verify';
|
|
||||||
|
|
||||||
export function MergeWorktreeDialog({
|
export function MergeWorktreeDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
projectPath,
|
projectPath,
|
||||||
worktree,
|
worktree,
|
||||||
onMerged,
|
onMerged,
|
||||||
affectedFeatureCount = 0,
|
onCreateConflictResolutionFeature,
|
||||||
}: MergeWorktreeDialogProps) {
|
}: MergeWorktreeDialogProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [step, setStep] = useState<DialogStep>('confirm');
|
const [targetBranch, setTargetBranch] = useState('main');
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
|
||||||
|
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||||
|
const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
|
||||||
|
const [mergeConflict, setMergeConflict] = useState<MergeConflictInfo | null>(null);
|
||||||
|
|
||||||
|
// Fetch available branches when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree && projectPath) {
|
||||||
|
setLoadingBranches(true);
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.worktree?.listBranches) {
|
||||||
|
api.worktree
|
||||||
|
.listBranches(projectPath, false)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.result?.branches) {
|
||||||
|
// Filter out the source branch (can't merge into itself) and remote branches
|
||||||
|
const branches = result.result.branches
|
||||||
|
.filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
|
||||||
|
.map((b: BranchInfo) => b.name);
|
||||||
|
setAvailableBranches(branches);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch branches:', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingBranches(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoadingBranches(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, worktree, projectPath]);
|
||||||
|
|
||||||
// Reset state when dialog opens
|
// Reset state when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStep('confirm');
|
setTargetBranch('main');
|
||||||
setConfirmText('');
|
setDeleteWorktreeAndBranch(false);
|
||||||
|
setMergeConflict(null);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleProceedToVerify = () => {
|
|
||||||
setStep('verify');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMerge = async () => {
|
const handleMerge = async () => {
|
||||||
if (!worktree) return;
|
if (!worktree) return;
|
||||||
|
|
||||||
@@ -71,96 +94,151 @@ export function MergeWorktreeDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass branchName and worktreePath directly to the API
|
// Pass branchName, worktreePath, targetBranch, and options to the API
|
||||||
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
|
const result = await api.worktree.mergeFeature(
|
||||||
|
projectPath,
|
||||||
|
worktree.branch,
|
||||||
|
worktree.path,
|
||||||
|
targetBranch,
|
||||||
|
{ deleteWorktreeAndBranch }
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success('Branch merged to main', {
|
const description = deleteWorktreeAndBranch
|
||||||
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
|
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
|
||||||
});
|
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
|
||||||
onMerged(worktree);
|
toast.success(`Branch merged to ${targetBranch}`, { description });
|
||||||
|
onMerged(worktree, deleteWorktreeAndBranch);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
// Check if the error indicates merge conflicts
|
||||||
|
const errorMessage = result.error || '';
|
||||||
|
const hasConflicts =
|
||||||
|
errorMessage.toLowerCase().includes('conflict') ||
|
||||||
|
errorMessage.toLowerCase().includes('merge failed') ||
|
||||||
|
errorMessage.includes('CONFLICT');
|
||||||
|
|
||||||
|
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||||
|
// Set merge conflict state to show the conflict resolution UI
|
||||||
|
setMergeConflict({
|
||||||
|
sourceBranch: worktree.branch,
|
||||||
|
targetBranch: targetBranch,
|
||||||
|
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
|
||||||
|
});
|
||||||
|
toast.error('Merge conflicts detected', {
|
||||||
|
description: 'The merge has conflicts that need to be resolved manually.',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to merge branch', {
|
toast.error('Failed to merge branch', {
|
||||||
description: result.error,
|
description: result.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Failed to merge branch', {
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
description: err instanceof Error ? err.message : 'Unknown error',
|
// Check if the error indicates merge conflicts
|
||||||
|
const hasConflicts =
|
||||||
|
errorMessage.toLowerCase().includes('conflict') ||
|
||||||
|
errorMessage.toLowerCase().includes('merge failed') ||
|
||||||
|
errorMessage.includes('CONFLICT');
|
||||||
|
|
||||||
|
if (hasConflicts && onCreateConflictResolutionFeature) {
|
||||||
|
setMergeConflict({
|
||||||
|
sourceBranch: worktree.branch,
|
||||||
|
targetBranch: targetBranch,
|
||||||
|
targetWorktreePath: projectPath,
|
||||||
});
|
});
|
||||||
|
toast.error('Merge conflicts detected', {
|
||||||
|
description: 'The merge has conflicts that need to be resolved manually.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to merge branch', {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateConflictResolutionFeature = () => {
|
||||||
|
if (mergeConflict && onCreateConflictResolutionFeature) {
|
||||||
|
onCreateConflictResolutionFeature(mergeConflict);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!worktree) return null;
|
if (!worktree) return null;
|
||||||
|
|
||||||
const confirmationWord = 'merge';
|
// Show conflict resolution UI if there are merge conflicts
|
||||||
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
|
if (mergeConflict) {
|
||||||
|
|
||||||
// First step: Show what will happen and ask for confirmation
|
|
||||||
if (step === 'confirm') {
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<GitMerge className="w-5 h-5 text-green-600" />
|
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||||
Merge to Main
|
Merge Conflicts Detected
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<span className="block">
|
<span className="block">
|
||||||
Merge branch{' '}
|
There are conflicts when merging{' '}
|
||||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
main?
|
{mergeConflict.sourceBranch}
|
||||||
|
</code>{' '}
|
||||||
|
into{' '}
|
||||||
|
<code className="font-mono bg-muted px-1 rounded">
|
||||||
|
{mergeConflict.targetBranch}
|
||||||
|
</code>
|
||||||
|
.
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground mt-2">
|
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||||
This will:
|
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
<span className="text-orange-500 text-sm">
|
||||||
<li>Merge the branch into the main branch</li>
|
The merge could not be completed automatically. You can create a feature task to
|
||||||
<li>Remove the worktree directory</li>
|
resolve the conflicts in the{' '}
|
||||||
<li>Delete the branch</li>
|
<code className="font-mono bg-muted px-0.5 rounded">
|
||||||
|
{mergeConflict.targetBranch}
|
||||||
|
</code>{' '}
|
||||||
|
branch.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will create a high-priority feature task that will:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
|
||||||
|
<li>
|
||||||
|
Resolve merge conflicts in the{' '}
|
||||||
|
<code className="font-mono bg-muted px-0.5 rounded">
|
||||||
|
{mergeConflict.targetBranch}
|
||||||
|
</code>{' '}
|
||||||
|
branch
|
||||||
|
</li>
|
||||||
|
<li>Ensure the code compiles and tests pass</li>
|
||||||
|
<li>Complete the merge automatically</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-yellow-500 text-sm">
|
|
||||||
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
|
||||||
commit or discard them before merging.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{affectedFeatureCount > 0 && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-blue-500 text-sm">
|
|
||||||
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
|
|
||||||
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
|
|
||||||
be unassigned after merge.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleProceedToVerify}
|
onClick={handleCreateConflictResolutionFeature}
|
||||||
disabled={worktree.hasChanges}
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
>
|
>
|
||||||
<GitMerge className="w-4 h-4 mr-2" />
|
<Wrench className="w-4 h-4 mr-2" />
|
||||||
Continue
|
Create Resolve Conflicts Feature
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -168,52 +246,86 @@ export function MergeWorktreeDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second step: Type confirmation
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
<GitMerge className="w-5 h-5 text-green-600" />
|
||||||
Confirm Merge
|
Merge Branch
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
<span className="block">
|
||||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
|
||||||
<span className="text-orange-600 dark:text-orange-400 text-sm">
|
into:
|
||||||
This action cannot be undone. The branch{' '}
|
|
||||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
|
|
||||||
permanently deleted after merging.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
|
<Label htmlFor="target-branch" className="text-sm text-foreground">
|
||||||
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
|
Target Branch
|
||||||
confirm:
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{loadingBranches ? (
|
||||||
id="confirm-merge"
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
value={confirmText}
|
<Spinner size="sm" />
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
Loading branches...
|
||||||
placeholder={confirmationWord}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="font-mono"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<BranchAutocomplete
|
||||||
|
value={targetBranch}
|
||||||
|
onChange={setTargetBranch}
|
||||||
|
branches={availableBranches}
|
||||||
|
placeholder="Select target branch..."
|
||||||
|
data-testid="merge-target-branch"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{worktree.hasChanges && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-yellow-500 text-sm">
|
||||||
|
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
|
||||||
|
commit or discard them before merging.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 py-2">
|
||||||
|
<Checkbox
|
||||||
|
id="delete-worktree-branch"
|
||||||
|
checked={deleteWorktreeAndBranch}
|
||||||
|
onCheckedChange={(checked) => setDeleteWorktreeAndBranch(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="delete-worktree-branch"
|
||||||
|
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||||
|
Delete worktree and branch after merging
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deleteWorktreeAndBranch && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-orange-500 text-sm">
|
||||||
|
The worktree and branch will be permanently deleted. Any features assigned to this
|
||||||
|
branch will be unassigned.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
Back
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleMerge}
|
onClick={handleMerge}
|
||||||
disabled={isLoading || !isConfirmValid}
|
disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
|
||||||
className="bg-green-600 hover:bg-green-700 text-white"
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -223,8 +335,8 @@ export function MergeWorktreeDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
<GitMerge className="w-4 h-4 mr-2" />
|
||||||
Merge to Main
|
Merge
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
|
||||||
|
interface WorktreeInfo {
|
||||||
|
path: string;
|
||||||
|
branch: string;
|
||||||
|
isMain: boolean;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
changedFilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteBranch {
|
||||||
|
name: string;
|
||||||
|
fullRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteInfo {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
branches: RemoteBranch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = createLogger('PullResolveConflictsDialog');
|
||||||
|
|
||||||
|
interface PullResolveConflictsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PullResolveConflictsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onConfirm,
|
||||||
|
}: PullResolveConflictsDialogProps) {
|
||||||
|
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||||
|
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch remotes when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
fetchRemotes();
|
||||||
|
}
|
||||||
|
}, [open, worktree]);
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedRemote('');
|
||||||
|
setSelectedBranch('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Auto-select default remote and branch when remotes are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (remotes.length > 0 && !selectedRemote) {
|
||||||
|
// Default to 'origin' if available, otherwise first remote
|
||||||
|
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
|
||||||
|
setSelectedRemote(defaultRemote.name);
|
||||||
|
|
||||||
|
// Try to select a matching branch name or default to main/master
|
||||||
|
if (defaultRemote.branches.length > 0 && worktree) {
|
||||||
|
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
|
||||||
|
const mainBranch = defaultRemote.branches.find(
|
||||||
|
(b) => b.name === 'main' || b.name === 'master'
|
||||||
|
);
|
||||||
|
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
|
||||||
|
setSelectedBranch(defaultBranch.fullRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [remotes, selectedRemote, worktree]);
|
||||||
|
|
||||||
|
// Update selected branch when remote changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRemote && remotes.length > 0 && worktree) {
|
||||||
|
const remote = remotes.find((r) => r.name === selectedRemote);
|
||||||
|
if (remote && remote.branches.length > 0) {
|
||||||
|
// Try to select a matching branch name or default to main/master
|
||||||
|
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
|
||||||
|
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
|
||||||
|
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
|
||||||
|
setSelectedBranch(defaultBranch.fullRef);
|
||||||
|
} else {
|
||||||
|
setSelectedBranch('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedRemote, remotes, worktree]);
|
||||||
|
|
||||||
|
const fetchRemotes = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
setRemotes(result.result.remotes);
|
||||||
|
if (result.result.remotes.length === 0) {
|
||||||
|
setError('No remotes found in this repository');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to fetch remotes');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch remotes:', err);
|
||||||
|
setError('Failed to fetch remotes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
setRemotes(result.result.remotes);
|
||||||
|
toast.success('Remotes refreshed');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to refresh remotes');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to refresh remotes:', err);
|
||||||
|
toast.error('Failed to refresh remotes');
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!worktree || !selectedBranch) return;
|
||||||
|
onConfirm(worktree, selectedBranch);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
|
||||||
|
const branches = selectedRemoteData?.branches || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||||
|
Pull & Resolve Conflicts
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a remote branch to pull from and resolve conflicts with{' '}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{worktree?.branch || 'current branch'}
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchRemotes}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="remote-select">Remote</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<Spinner size="xs" className="mr-1" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||||
|
<SelectTrigger id="remote-select">
|
||||||
|
<SelectValue placeholder="Select a remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<SelectItem key={remote.name} value={remote.name}>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium">{remote.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="branch-select">Branch</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedBranch}
|
||||||
|
onValueChange={setSelectedBranch}
|
||||||
|
disabled={!selectedRemote || branches.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="branch-select">
|
||||||
|
<SelectValue placeholder="Select a branch" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>{selectedRemote} branches</SelectLabel>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<SelectItem key={branch.fullRef} value={branch.fullRef}>
|
||||||
|
{branch.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedRemote && branches.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBranch && (
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will create a feature task to pull from{' '}
|
||||||
|
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
|
||||||
|
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
|
||||||
|
any merge conflicts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selectedBranch || isLoading}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-4 h-4 mr-2" />
|
||||||
|
Pull & Resolve
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import type { WorktreeInfo } from '../worktree-panel/types';
|
||||||
|
|
||||||
|
interface RemoteInfo {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = createLogger('PushToRemoteDialog');
|
||||||
|
|
||||||
|
interface PushToRemoteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
worktree: WorktreeInfo | null;
|
||||||
|
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PushToRemoteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
worktree,
|
||||||
|
onConfirm,
|
||||||
|
}: PushToRemoteDialogProps) {
|
||||||
|
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
|
||||||
|
const [selectedRemote, setSelectedRemote] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch remotes when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
fetchRemotes();
|
||||||
|
}
|
||||||
|
}, [open, worktree]);
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedRemote('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Auto-select default remote when remotes are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (remotes.length > 0 && !selectedRemote) {
|
||||||
|
// Default to 'origin' if available, otherwise first remote
|
||||||
|
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
|
||||||
|
setSelectedRemote(defaultRemote.name);
|
||||||
|
}
|
||||||
|
}, [remotes, selectedRemote]);
|
||||||
|
|
||||||
|
const fetchRemotes = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
// Extract just the remote info (name and URL), not the branches
|
||||||
|
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
}));
|
||||||
|
setRemotes(remoteInfos);
|
||||||
|
if (remoteInfos.length === 0) {
|
||||||
|
setError('No remotes found in this repository. Please add a remote first.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Failed to fetch remotes');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch remotes:', err);
|
||||||
|
setError('Failed to fetch remotes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (!worktree) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.worktree.listRemotes(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.result) {
|
||||||
|
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
}));
|
||||||
|
setRemotes(remoteInfos);
|
||||||
|
toast.success('Remotes refreshed');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to refresh remotes');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to refresh remotes:', err);
|
||||||
|
toast.error('Failed to refresh remotes');
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!worktree || !selectedRemote) return;
|
||||||
|
onConfirm(worktree, selectedRemote);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[450px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-primary" />
|
||||||
|
Push New Branch to Remote
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
new
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Push{' '}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{worktree?.branch || 'current branch'}
|
||||||
|
</span>{' '}
|
||||||
|
to a remote repository for the first time.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchRemotes}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="remote-select">Select Remote</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{isRefreshing ? (
|
||||||
|
<Spinner size="xs" className="mr-1" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
|
||||||
|
<SelectTrigger id="remote-select">
|
||||||
|
<SelectValue placeholder="Select a remote" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<SelectItem key={remote.name} value={remote.name}>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium">{remote.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||||
|
{remote.url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRemote && (
|
||||||
|
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This will create a new remote branch{' '}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{selectedRemote}/{worktree?.branch}
|
||||||
|
</span>{' '}
|
||||||
|
and set up tracking.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Push to {selectedRemote || 'Remote'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
|
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||||
import { truncateDescription } from '@/lib/utils';
|
import { truncateDescription } from '@/lib/utils';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
@@ -91,9 +92,14 @@ export function useBoardActions({
|
|||||||
skipVerificationInAutoMode,
|
skipVerificationInAutoMode,
|
||||||
isPrimaryWorktreeBranch,
|
isPrimaryWorktreeBranch,
|
||||||
getPrimaryWorktreeBranch,
|
getPrimaryWorktreeBranch,
|
||||||
|
getAutoModeState,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
|
// React Query mutations for feature operations
|
||||||
|
const verifyFeatureMutation = useVerifyFeature(currentProject?.path ?? '');
|
||||||
|
const resumeFeatureMutation = useResumeFeature(currentProject?.path ?? '');
|
||||||
|
|
||||||
// Worktrees are created when adding/editing features with a branch name
|
// Worktrees are created when adding/editing features with a branch name
|
||||||
// This ensures the worktree exists before the feature starts execution
|
// This ensures the worktree exists before the feature starts execution
|
||||||
|
|
||||||
@@ -480,10 +486,22 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleStartImplementation = useCallback(
|
const handleStartImplementation = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
if (!autoMode.canStartNewTask) {
|
// Check capacity for the feature's specific worktree, not the current view
|
||||||
|
const featureBranchName = feature.branchName ?? null;
|
||||||
|
const featureWorktreeState = currentProject
|
||||||
|
? getAutoModeState(currentProject.id, featureBranchName)
|
||||||
|
: null;
|
||||||
|
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
|
||||||
|
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||||
|
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||||
|
|
||||||
|
if (!canStartInWorktree) {
|
||||||
|
const worktreeDesc = featureBranchName
|
||||||
|
? `worktree "${featureBranchName}"`
|
||||||
|
: 'main worktree';
|
||||||
toast.error('Concurrency limit reached', {
|
toast.error('Concurrency limit reached', {
|
||||||
description: `You can only have ${autoMode.maxConcurrency} task${
|
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
||||||
autoMode.maxConcurrency > 1 ? 's' : ''
|
featureMaxConcurrency > 1 ? 's' : ''
|
||||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -547,34 +565,17 @@ export function useBoardActions({
|
|||||||
updateFeature,
|
updateFeature,
|
||||||
persistFeatureUpdate,
|
persistFeatureUpdate,
|
||||||
handleRunFeature,
|
handleRunFeature,
|
||||||
|
currentProject,
|
||||||
|
getAutoModeState,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVerifyFeature = useCallback(
|
const handleVerifyFeature = useCallback(
|
||||||
async (feature: Feature) => {
|
async (feature: Feature) => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
verifyFeatureMutation.mutate(feature.id);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode) {
|
|
||||||
logger.error('Auto mode API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.autoMode.verifyFeature(currentProject.path, feature.id);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info('Feature verification started successfully');
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to verify feature:', result.error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error verifying feature:', error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[currentProject, loadFeatures]
|
[currentProject, verifyFeatureMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleResumeFeature = useCallback(
|
const handleResumeFeature = useCallback(
|
||||||
@@ -584,40 +585,9 @@ export function useBoardActions({
|
|||||||
logger.error('No current project');
|
logger.error('No current project');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode) {
|
|
||||||
logger.error('Auto mode API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Calling resumeFeature API...', {
|
|
||||||
projectPath: currentProject.path,
|
|
||||||
featureId: feature.id,
|
|
||||||
useWorktrees,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await api.autoMode.resumeFeature(
|
|
||||||
currentProject.path,
|
|
||||||
feature.id,
|
|
||||||
useWorktrees
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info('resumeFeature result:', result);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info('Feature resume started successfully');
|
|
||||||
} else {
|
|
||||||
logger.error('Failed to resume feature:', result.error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error resuming feature:', error);
|
|
||||||
await loadFeatures();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[currentProject, loadFeatures, useWorktrees]
|
[currentProject, resumeFeatureMutation, useWorktrees]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleManualVerify = useCallback(
|
const handleManualVerify = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
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 { resolveDependencies, getBlockingDependencies } from '@automaker/dependency-resolver';
|
import {
|
||||||
|
createFeatureMap,
|
||||||
|
getBlockingDependenciesFromMap,
|
||||||
|
resolveDependencies,
|
||||||
|
} from '@automaker/dependency-resolver';
|
||||||
|
|
||||||
type ColumnId = Feature['status'];
|
type ColumnId = Feature['status'];
|
||||||
|
|
||||||
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
|
|||||||
verified: [],
|
verified: [],
|
||||||
completed: [], // Completed features are shown in the archive modal, not as a column
|
completed: [], // Completed features are shown in the archive modal, not as a column
|
||||||
};
|
};
|
||||||
|
const featureMap = createFeatureMap(features);
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
|
|
||||||
// Filter features by search query (case-insensitive)
|
// Filter features by search query (case-insensitive)
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
|
|||||||
|
|
||||||
filteredFeatures.forEach((f) => {
|
filteredFeatures.forEach((f) => {
|
||||||
// If feature has a running agent, always show it in "in_progress"
|
// If feature has a running agent, always show it in "in_progress"
|
||||||
const isRunning = runningAutoTasks.includes(f.id);
|
const isRunning = runningTaskIds.has(f.id);
|
||||||
|
|
||||||
// Check if feature matches the current worktree by branchName
|
// Check if feature matches the current worktree by branchName
|
||||||
// Features without branchName are considered unassigned (show only on primary worktree)
|
// Features without branchName are considered unassigned (show only on primary worktree)
|
||||||
@@ -168,7 +174,6 @@ export function useBoardColumnFeatures({
|
|||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
|
|
||||||
// Get all features to check blocking dependencies against
|
// Get all features to check blocking dependencies against
|
||||||
const allFeatures = features;
|
|
||||||
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
|
||||||
|
|
||||||
// Sort blocked features to the end of the backlog
|
// Sort blocked features to the end of the backlog
|
||||||
@@ -178,7 +183,7 @@ export function useBoardColumnFeatures({
|
|||||||
const blocked: Feature[] = [];
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
for (const f of orderedFeatures) {
|
for (const f of orderedFeatures) {
|
||||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
|
||||||
blocked.push(f);
|
blocked.push(f);
|
||||||
} else {
|
} else {
|
||||||
unblocked.push(f);
|
unblocked.push(f);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
|
|||||||
|
|
||||||
const logger = createLogger('BoardDragDrop');
|
const logger = createLogger('BoardDragDrop');
|
||||||
|
|
||||||
|
export interface PendingDependencyLink {
|
||||||
|
draggedFeature: Feature;
|
||||||
|
targetFeature: Feature;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseBoardDragDropProps {
|
interface UseBoardDragDropProps {
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
currentProject: { path: string; id: string } | null;
|
currentProject: { path: string; id: string } | null;
|
||||||
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
|
|||||||
handleStartImplementation,
|
handleStartImplementation,
|
||||||
}: UseBoardDragDropProps) {
|
}: UseBoardDragDropProps) {
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
const { moveFeature } = useAppStore();
|
const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const { moveFeature, updateFeature } = useAppStore();
|
||||||
|
|
||||||
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
|
||||||
// at execution time based on feature.branchName
|
// at execution time based on feature.branchName
|
||||||
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
|
|||||||
[features]
|
[features]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clear pending dependency link
|
||||||
|
const clearPendingDependencyLink = useCallback(() => {
|
||||||
|
setPendingDependencyLink(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
async (event: DragEndEvent) => {
|
async (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
@@ -57,6 +70,85 @@ export function useBoardDragDrop({
|
|||||||
// Check if this is a running task (non-skipTests, TDD)
|
// Check if this is a running task (non-skipTests, TDD)
|
||||||
const isRunningTask = runningAutoTasks.includes(featureId);
|
const isRunningTask = runningAutoTasks.includes(featureId);
|
||||||
|
|
||||||
|
// Check if dropped on another card (for creating dependency links)
|
||||||
|
if (overId.startsWith('card-drop-')) {
|
||||||
|
const cardData = over.data.current as {
|
||||||
|
type: string;
|
||||||
|
featureId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cardData?.type === 'card') {
|
||||||
|
const targetFeatureId = cardData.featureId;
|
||||||
|
|
||||||
|
// Don't link to self
|
||||||
|
if (targetFeatureId === featureId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||||
|
if (!targetFeature) return;
|
||||||
|
|
||||||
|
// Only allow linking backlog features (both must be in backlog)
|
||||||
|
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
||||||
|
toast.error('Cannot link features', {
|
||||||
|
description: 'Both features must be in the backlog to create a dependency link.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pending dependency link to trigger dialog
|
||||||
|
setPendingDependencyLink({
|
||||||
|
draggedFeature,
|
||||||
|
targetFeature,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dropped on a worktree tab
|
||||||
|
if (overId.startsWith('worktree-drop-')) {
|
||||||
|
// Handle dropping on a worktree - change the feature's branchName
|
||||||
|
const worktreeData = over.data.current as {
|
||||||
|
type: string;
|
||||||
|
branch: string;
|
||||||
|
path: string;
|
||||||
|
isMain: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (worktreeData?.type === 'worktree') {
|
||||||
|
// Don't allow moving running tasks to a different worktree
|
||||||
|
if (isRunningTask) {
|
||||||
|
logger.debug('Cannot move running feature to different worktree');
|
||||||
|
toast.error('Cannot move feature', {
|
||||||
|
description: 'This feature is currently running and cannot be moved.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBranch = worktreeData.branch;
|
||||||
|
const currentBranch = draggedFeature.branchName;
|
||||||
|
|
||||||
|
// If already on the same branch, nothing to do
|
||||||
|
if (currentBranch === targetBranch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For main worktree, set branchName to undefined/null to indicate it should use main
|
||||||
|
// For other worktrees, set branchName to the target branch
|
||||||
|
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
|
||||||
|
|
||||||
|
// Update feature's branchName
|
||||||
|
updateFeature(featureId, { branchName: newBranchName });
|
||||||
|
await persistFeatureUpdate(featureId, { branchName: newBranchName });
|
||||||
|
|
||||||
|
const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
|
||||||
|
toast.success('Feature moved to branch', {
|
||||||
|
description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if dragging is allowed based on status and skipTests
|
// Determine if dragging is allowed based on status and skipTests
|
||||||
// - Backlog items can always be dragged
|
// - Backlog items can always be dragged
|
||||||
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
// - waiting_approval items can always be dragged (to allow manual verification via drag)
|
||||||
@@ -205,12 +297,21 @@ export function useBoardDragDrop({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation]
|
[
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
moveFeature,
|
||||||
|
updateFeature,
|
||||||
|
persistFeatureUpdate,
|
||||||
|
handleStartImplementation,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeFeature,
|
activeFeature,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
pendingDependencyLink,
|
||||||
|
clearPendingDependencyLink,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
const logger = createLogger('BoardEffects');
|
const logger = createLogger('BoardEffects');
|
||||||
@@ -65,37 +64,8 @@ export function useBoardEffects({
|
|||||||
};
|
};
|
||||||
}, [specCreatingForProject, setSpecCreatingForProject]);
|
}, [specCreatingForProject, setSpecCreatingForProject]);
|
||||||
|
|
||||||
// Sync running tasks from electron backend on mount
|
// Note: Running tasks sync is now handled by useAutoMode hook in BoardView
|
||||||
useEffect(() => {
|
// which correctly handles worktree/branch scoping.
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
const syncRunningTasks = async () => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.autoMode?.status) return;
|
|
||||||
|
|
||||||
const status = await api.autoMode.status(currentProject.path);
|
|
||||||
if (status.success) {
|
|
||||||
const projectId = currentProject.id;
|
|
||||||
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
|
||||||
|
|
||||||
if (status.runningFeatures) {
|
|
||||||
logger.info('Syncing running tasks from backend:', status.runningFeatures);
|
|
||||||
|
|
||||||
clearRunningTasks(projectId);
|
|
||||||
|
|
||||||
status.runningFeatures.forEach((featureId: string) => {
|
|
||||||
addRunningTask(projectId, featureId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to sync running tasks:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
syncRunningTasks();
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
// Check which features have context files
|
// Check which features have context files
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
/**
|
||||||
import { useAppStore, Feature } from '@/store/app-store';
|
* Board Features Hook
|
||||||
|
*
|
||||||
|
* React Query-based hook for managing features on the board view.
|
||||||
|
* Handles feature loading, categories, and auto-mode event notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { useFeatures } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
const logger = createLogger('BoardFeatures');
|
const logger = createLogger('BoardFeatures');
|
||||||
|
|
||||||
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
||||||
const { features, setFeatures } = useAppStore();
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
// Track previous project path to detect project switches
|
// Use React Query for features
|
||||||
const prevProjectPathRef = useRef<string | null>(null);
|
const {
|
||||||
const isInitialLoadRef = useRef(true);
|
data: features = [],
|
||||||
const isSwitchingProjectRef = useRef(false);
|
isLoading,
|
||||||
|
refetch: loadFeatures,
|
||||||
// Load features using features API
|
} = useFeatures(currentProject?.path);
|
||||||
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
|
||||||
const loadFeatures = useCallback(async () => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
const currentPath = currentProject.path;
|
|
||||||
const previousPath = prevProjectPathRef.current;
|
|
||||||
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
|
|
||||||
|
|
||||||
// Get cached features from store (without adding to dependencies)
|
|
||||||
const cachedFeatures = useAppStore.getState().features;
|
|
||||||
|
|
||||||
// If project switched, mark it but don't clear features yet
|
|
||||||
// We'll clear after successful API load to prevent data loss
|
|
||||||
if (isProjectSwitch) {
|
|
||||||
logger.info(`Project switch detected: ${previousPath} -> ${currentPath}`);
|
|
||||||
isSwitchingProjectRef.current = true;
|
|
||||||
isInitialLoadRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the ref to track current project
|
|
||||||
prevProjectPathRef.current = currentPath;
|
|
||||||
|
|
||||||
// Only show loading spinner on initial load to prevent board flash during reloads
|
|
||||||
if (isInitialLoadRef.current) {
|
|
||||||
setIsLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.features) {
|
|
||||||
logger.error('Features API not available');
|
|
||||||
// Keep cached features if API is unavailable
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.features.getAll(currentProject.path);
|
|
||||||
|
|
||||||
if (result.success && result.features) {
|
|
||||||
const featuresWithIds = result.features.map((f: any, index: number) => ({
|
|
||||||
...f,
|
|
||||||
id: f.id || `feature-${index}-${Date.now()}`,
|
|
||||||
status: f.status || 'backlog',
|
|
||||||
startedAt: f.startedAt, // Preserve startedAt timestamp
|
|
||||||
// Ensure model and thinkingLevel are set for backward compatibility
|
|
||||||
model: f.model || 'opus',
|
|
||||||
thinkingLevel: f.thinkingLevel || 'none',
|
|
||||||
}));
|
|
||||||
// Successfully loaded features - now safe to set them
|
|
||||||
setFeatures(featuresWithIds);
|
|
||||||
|
|
||||||
// Only clear categories on project switch AFTER successful load
|
|
||||||
if (isProjectSwitch) {
|
|
||||||
setPersistedCategories([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for interrupted features and resume them
|
|
||||||
// This handles server restarts where features were in pipeline steps
|
|
||||||
if (api.autoMode?.resumeInterrupted) {
|
|
||||||
try {
|
|
||||||
await api.autoMode.resumeInterrupted(currentProject.path);
|
|
||||||
logger.info('Checked for interrupted features');
|
|
||||||
} catch (resumeError) {
|
|
||||||
logger.warn('Failed to check for interrupted features:', resumeError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!result.success && result.error) {
|
|
||||||
logger.error('API returned error:', result.error);
|
|
||||||
// If it's a new project or the error indicates no features found,
|
|
||||||
// that's expected - start with empty array
|
|
||||||
if (isProjectSwitch) {
|
|
||||||
setFeatures([]);
|
|
||||||
setPersistedCategories([]);
|
|
||||||
}
|
|
||||||
// Otherwise keep cached features
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load features:', error);
|
|
||||||
// On error, keep existing cached features for the current project
|
|
||||||
// Only clear on project switch if we have no features from server
|
|
||||||
if (isProjectSwitch && cachedFeatures.length === 0) {
|
|
||||||
setFeatures([]);
|
|
||||||
setPersistedCategories([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
isInitialLoadRef.current = false;
|
|
||||||
isSwitchingProjectRef.current = false;
|
|
||||||
}
|
|
||||||
}, [currentProject, setFeatures]);
|
|
||||||
|
|
||||||
// Load persisted categories from file
|
// Load persisted categories from file
|
||||||
const loadCategories = useCallback(async () => {
|
const loadCategories = useCallback(async () => {
|
||||||
@@ -125,15 +45,12 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
setPersistedCategories(parsed);
|
setPersistedCategories(parsed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// File doesn't exist, ensure categories are cleared
|
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
logger.error('Failed to load categories:', error);
|
|
||||||
// If file doesn't exist, ensure categories are cleared
|
|
||||||
setPersistedCategories([]);
|
setPersistedCategories([]);
|
||||||
}
|
}
|
||||||
}, [currentProject]);
|
}, [currentProject, loadFeatures]);
|
||||||
|
|
||||||
// Save a new category to the persisted categories file
|
// Save a new category to the persisted categories file
|
||||||
const saveCategory = useCallback(
|
const saveCategory = useCallback(
|
||||||
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
|
||||||
// Read existing categories
|
|
||||||
let categories: string[] = [...persistedCategories];
|
let categories: string[] = [...persistedCategories];
|
||||||
|
|
||||||
// Add new category if it doesn't exist
|
|
||||||
if (!categories.includes(category)) {
|
if (!categories.includes(category)) {
|
||||||
categories.push(category);
|
categories.push(category);
|
||||||
categories.sort(); // Keep sorted
|
categories.sort();
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
await api.writeFile(
|
await api.writeFile(
|
||||||
`${currentProject.path}/.automaker/categories.json`,
|
`${currentProject.path}/.automaker/categories.json`,
|
||||||
JSON.stringify(categories, null, 2)
|
JSON.stringify(categories, null, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
|
||||||
setPersistedCategories(categories);
|
setPersistedCategories(categories);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
[currentProject, persistedCategories]
|
[currentProject, persistedCategories]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to spec regeneration complete events to refresh kanban board
|
// Subscribe to auto mode events for notifications (ding sound, toasts)
|
||||||
useEffect(() => {
|
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) return;
|
|
||||||
|
|
||||||
const unsubscribe = api.specRegeneration.onEvent((event) => {
|
|
||||||
// Refresh the kanban board when spec regeneration completes for the current project
|
|
||||||
if (
|
|
||||||
event.type === 'spec_regeneration_complete' &&
|
|
||||||
currentProject &&
|
|
||||||
event.projectPath === currentProject.path
|
|
||||||
) {
|
|
||||||
logger.info('Spec regeneration complete, refreshing features');
|
|
||||||
loadFeatures();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [currentProject, loadFeatures]);
|
|
||||||
|
|
||||||
// Listen for auto mode feature completion and errors to reload features
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.autoMode || !currentProject) return;
|
if (!api?.autoMode || !currentProject) return;
|
||||||
@@ -229,28 +120,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
const audio = new Audio('/sounds/ding.mp3');
|
const audio = new Audio('/sounds/ding.mp3');
|
||||||
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
|
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
|
||||||
}
|
}
|
||||||
} else if (event.type === 'plan_approval_required') {
|
|
||||||
// Reload features when plan is generated and requires approval
|
|
||||||
// This ensures the feature card shows the "Approve Plan" button
|
|
||||||
logger.info('Plan approval required, reloading features...');
|
|
||||||
loadFeatures();
|
|
||||||
} else if (event.type === 'pipeline_step_started') {
|
|
||||||
// Pipeline steps update the feature status to `pipeline_*` before the step runs.
|
|
||||||
// Reload so the card moves into the correct pipeline column immediately.
|
|
||||||
logger.info('Pipeline step started, reloading features...');
|
|
||||||
loadFeatures();
|
|
||||||
} else if (event.type === 'auto_mode_error') {
|
} else if (event.type === 'auto_mode_error') {
|
||||||
// Reload features when an error occurs (feature moved to waiting_approval)
|
// Remove from running tasks
|
||||||
logger.info('Feature error, reloading features...', event.error);
|
|
||||||
|
|
||||||
// Remove from running tasks so it moves to the correct column
|
|
||||||
if (event.featureId) {
|
if (event.featureId) {
|
||||||
removeRunningTask(eventProjectId, event.featureId);
|
const eventBranchName =
|
||||||
|
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
|
||||||
|
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFeatures();
|
// Show error toast
|
||||||
|
|
||||||
// Check for authentication errors and show a more helpful message
|
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
event.errorType === 'authentication' ||
|
event.errorType === 'authentication' ||
|
||||||
(event.error &&
|
(event.error &&
|
||||||
@@ -272,22 +150,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [loadFeatures, currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Check for interrupted features on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFeatures();
|
if (!currentProject) return;
|
||||||
}, [loadFeatures]);
|
|
||||||
|
|
||||||
// Load persisted categories on mount
|
const checkInterrupted = async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.autoMode?.resumeInterrupted) {
|
||||||
|
try {
|
||||||
|
await api.autoMode.resumeInterrupted(currentProject.path);
|
||||||
|
logger.info('Checked for interrupted features');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to check for interrupted features:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkInterrupted();
|
||||||
|
}, [currentProject]);
|
||||||
|
|
||||||
|
// Load persisted categories on mount/project change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCategories();
|
loadCategories();
|
||||||
}, [loadCategories]);
|
}, [loadCategories]);
|
||||||
|
|
||||||
|
// Clear categories when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPersistedCategories([]);
|
||||||
|
}, [currentProject?.path]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features,
|
features,
|
||||||
isLoading,
|
isLoading,
|
||||||
persistedCategories,
|
persistedCategories,
|
||||||
loadFeatures,
|
loadFeatures: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
|
||||||
|
});
|
||||||
|
},
|
||||||
loadCategories,
|
loadCategories,
|
||||||
saveCategory,
|
saveCategory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
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';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
const logger = createLogger('BoardPersistence');
|
const logger = createLogger('BoardPersistence');
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ interface UseBoardPersistenceProps {
|
|||||||
|
|
||||||
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
|
||||||
const { updateFeature } = useAppStore();
|
const { updateFeature } = useAppStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Persist feature update to API (replaces saveFeatures)
|
// Persist feature update to API (replaces saveFeatures)
|
||||||
const persistFeatureUpdate = useCallback(
|
const persistFeatureUpdate = useCallback(
|
||||||
@@ -45,7 +48,21 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
feature: result.feature,
|
feature: result.feature,
|
||||||
});
|
});
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, result.feature);
|
const updatedFeature = result.feature;
|
||||||
|
updateFeature(updatedFeature.id, updatedFeature);
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(currentProject.path),
|
||||||
|
(features) => {
|
||||||
|
if (!features) return features;
|
||||||
|
return features.map((feature) =>
|
||||||
|
feature.id === updatedFeature.id ? updatedFeature : feature
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
logger.error('API features.update failed', result);
|
logger.error('API features.update failed', result);
|
||||||
}
|
}
|
||||||
@@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
logger.error('Failed to persist feature update:', error);
|
logger.error('Failed to persist feature update:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature]
|
[currentProject, updateFeature, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist feature creation to API
|
// Persist feature creation to API
|
||||||
@@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
const result = await api.features.create(currentProject.path, feature);
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
if (result.success && result.feature) {
|
if (result.success && result.feature) {
|
||||||
updateFeature(result.feature.id, result.feature);
|
updateFeature(result.feature.id, result.feature);
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature creation:', error);
|
logger.error('Failed to persist feature creation:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject, updateFeature]
|
[currentProject, updateFeature, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist feature deletion to API
|
// Persist feature deletion to API
|
||||||
@@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.features.delete(currentProject.path, featureId);
|
await api.features.delete(currentProject.path, featureId);
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist feature deletion:', error);
|
logger.error('Failed to persist feature deletion:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject]
|
[currentProject, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import {
|
||||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
type RefObject,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { DragOverlay } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
|
||||||
@@ -10,10 +18,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
|
|||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
|
||||||
collisionDetectionStrategy: (args: any) => any;
|
|
||||||
onDragStart: (event: any) => void;
|
|
||||||
onDragEnd: (event: any) => void;
|
|
||||||
activeFeature: Feature | null;
|
activeFeature: Feature | null;
|
||||||
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
getColumnFeatures: (columnId: ColumnId) => Feature[];
|
||||||
backgroundImageStyle: React.CSSProperties;
|
backgroundImageStyle: React.CSSProperties;
|
||||||
@@ -64,11 +68,200 @@ interface KanbanBoardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KANBAN_VIRTUALIZATION_THRESHOLD = 40;
|
||||||
|
const KANBAN_CARD_ESTIMATED_HEIGHT_PX = 220;
|
||||||
|
const KANBAN_CARD_GAP_PX = 10;
|
||||||
|
const KANBAN_OVERSCAN_COUNT = 6;
|
||||||
|
const VIRTUALIZATION_MEASURE_EPSILON_PX = 1;
|
||||||
|
const REDUCED_CARD_OPACITY_PERCENT = 85;
|
||||||
|
|
||||||
|
type VirtualListItem = { id: string };
|
||||||
|
|
||||||
|
interface VirtualListState<Item extends VirtualListItem> {
|
||||||
|
contentRef: RefObject<HTMLDivElement>;
|
||||||
|
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||||
|
itemIds: string[];
|
||||||
|
visibleItems: Item[];
|
||||||
|
totalHeight: number;
|
||||||
|
offsetTop: number;
|
||||||
|
startIndex: number;
|
||||||
|
shouldVirtualize: boolean;
|
||||||
|
registerItem: (id: string) => (node: HTMLDivElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualizedListProps<Item extends VirtualListItem> {
|
||||||
|
items: Item[];
|
||||||
|
isDragging: boolean;
|
||||||
|
estimatedItemHeight: number;
|
||||||
|
itemGap: number;
|
||||||
|
overscan: number;
|
||||||
|
virtualizationThreshold: number;
|
||||||
|
children: (state: VirtualListState<Item>) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIndexForOffset(itemEnds: number[], offset: number): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = itemEnds.length - 1;
|
||||||
|
let result = itemEnds.length;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
if (itemEnds[mid] >= offset) {
|
||||||
|
result = mid;
|
||||||
|
high = mid - 1;
|
||||||
|
} else {
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(result, itemEnds.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Virtualize long columns while keeping full DOM during drag interactions.
|
||||||
|
function VirtualizedList<Item extends VirtualListItem>({
|
||||||
|
items,
|
||||||
|
isDragging,
|
||||||
|
estimatedItemHeight,
|
||||||
|
itemGap,
|
||||||
|
overscan,
|
||||||
|
virtualizationThreshold,
|
||||||
|
children,
|
||||||
|
}: VirtualizedListProps<Item>) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const measurementsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
|
const [measureVersion, setMeasureVersion] = useState(0);
|
||||||
|
|
||||||
|
const itemIds = useMemo(() => items.map((item) => item.id), [items]);
|
||||||
|
const shouldVirtualize = !isDragging && items.length >= virtualizationThreshold;
|
||||||
|
|
||||||
|
const itemSizes = useMemo(() => {
|
||||||
|
return items.map((item) => {
|
||||||
|
const measured = measurementsRef.current.get(item.id);
|
||||||
|
const resolvedHeight = measured ?? estimatedItemHeight;
|
||||||
|
return resolvedHeight + itemGap;
|
||||||
|
});
|
||||||
|
}, [items, estimatedItemHeight, itemGap, measureVersion]);
|
||||||
|
|
||||||
|
const itemStarts = useMemo(() => {
|
||||||
|
let offset = 0;
|
||||||
|
return itemSizes.map((size) => {
|
||||||
|
const start = offset;
|
||||||
|
offset += size;
|
||||||
|
return start;
|
||||||
|
});
|
||||||
|
}, [itemSizes]);
|
||||||
|
|
||||||
|
const itemEnds = useMemo(() => {
|
||||||
|
return itemStarts.map((start, index) => start + itemSizes[index]);
|
||||||
|
}, [itemStarts, itemSizes]);
|
||||||
|
|
||||||
|
const totalHeight = itemEnds.length > 0 ? itemEnds[itemEnds.length - 1] : 0;
|
||||||
|
|
||||||
|
const { startIndex, endIndex, offsetTop } = useMemo(() => {
|
||||||
|
if (!shouldVirtualize || items.length === 0) {
|
||||||
|
return { startIndex: 0, endIndex: items.length, offsetTop: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstVisible = findIndexForOffset(itemEnds, scrollTop);
|
||||||
|
const lastVisible = findIndexForOffset(itemEnds, scrollTop + viewportHeight);
|
||||||
|
const overscannedStart = Math.max(0, firstVisible - overscan);
|
||||||
|
const overscannedEnd = Math.min(items.length, lastVisible + overscan + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startIndex: overscannedStart,
|
||||||
|
endIndex: overscannedEnd,
|
||||||
|
offsetTop: itemStarts[overscannedStart] ?? 0,
|
||||||
|
};
|
||||||
|
}, [shouldVirtualize, items.length, itemEnds, itemStarts, overscan, scrollTop, viewportHeight]);
|
||||||
|
|
||||||
|
const visibleItems = shouldVirtualize ? items.slice(startIndex, endIndex) : items;
|
||||||
|
|
||||||
|
const onScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (scrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
}
|
||||||
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
|
setScrollTop(target.scrollTop);
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const registerItem = useCallback(
|
||||||
|
(id: string) => (node: HTMLDivElement | null) => {
|
||||||
|
if (!node || !shouldVirtualize) return;
|
||||||
|
const measuredHeight = node.getBoundingClientRect().height;
|
||||||
|
const previousHeight = measurementsRef.current.get(id);
|
||||||
|
if (
|
||||||
|
previousHeight === undefined ||
|
||||||
|
Math.abs(previousHeight - measuredHeight) > VIRTUALIZATION_MEASURE_EPSILON_PX
|
||||||
|
) {
|
||||||
|
measurementsRef.current.set(id, measuredHeight);
|
||||||
|
setMeasureVersion((value) => value + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[shouldVirtualize]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = contentRef.current;
|
||||||
|
if (!container || typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
setViewportHeight(container.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', updateHeight);
|
||||||
|
return () => window.removeEventListener('resize', updateHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => updateHeight());
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldVirtualize) return;
|
||||||
|
const currentIds = new Set(items.map((item) => item.id));
|
||||||
|
for (const id of measurementsRef.current.keys()) {
|
||||||
|
if (!currentIds.has(id)) {
|
||||||
|
measurementsRef.current.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items, shouldVirtualize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children({
|
||||||
|
contentRef,
|
||||||
|
onScroll,
|
||||||
|
itemIds,
|
||||||
|
visibleItems,
|
||||||
|
totalHeight,
|
||||||
|
offsetTop,
|
||||||
|
startIndex,
|
||||||
|
shouldVirtualize,
|
||||||
|
registerItem,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
sensors,
|
|
||||||
collisionDetectionStrategy,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
activeFeature,
|
activeFeature,
|
||||||
getColumnFeatures,
|
getColumnFeatures,
|
||||||
backgroundImageStyle,
|
backgroundImageStyle,
|
||||||
@@ -109,7 +302,7 @@ export function KanbanBoard({
|
|||||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||||
|
|
||||||
// Get the keyboard shortcut for adding features
|
// Get the keyboard shortcut for adding features
|
||||||
const { keyboardShortcuts } = useAppStore();
|
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||||
|
|
||||||
// Use responsive column widths based on window size
|
// Use responsive column widths based on window size
|
||||||
@@ -124,19 +317,32 @@ export function KanbanBoard({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={backgroundImageStyle}
|
style={backgroundImageStyle}
|
||||||
>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={collisionDetectionStrategy}
|
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
>
|
>
|
||||||
<div className="h-full py-1" style={containerStyle}>
|
<div className="h-full py-1" style={containerStyle}>
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
const columnFeatures = getColumnFeatures(column.id as ColumnId);
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<VirtualizedList
|
||||||
key={column.id}
|
key={column.id}
|
||||||
|
items={columnFeatures}
|
||||||
|
isDragging={isDragging}
|
||||||
|
estimatedItemHeight={KANBAN_CARD_ESTIMATED_HEIGHT_PX}
|
||||||
|
itemGap={KANBAN_CARD_GAP_PX}
|
||||||
|
overscan={KANBAN_OVERSCAN_COUNT}
|
||||||
|
virtualizationThreshold={KANBAN_VIRTUALIZATION_THRESHOLD}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
contentRef,
|
||||||
|
onScroll,
|
||||||
|
itemIds,
|
||||||
|
visibleItems,
|
||||||
|
totalHeight,
|
||||||
|
offsetTop,
|
||||||
|
startIndex,
|
||||||
|
shouldVirtualize,
|
||||||
|
registerItem,
|
||||||
|
}) => (
|
||||||
|
<KanbanColumn
|
||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
@@ -145,6 +351,10 @@ export function KanbanBoard({
|
|||||||
opacity={backgroundSettings.columnOpacity}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
|
contentRef={contentRef}
|
||||||
|
onScroll={shouldVirtualize ? onScroll : undefined}
|
||||||
|
disableItemSpacing={shouldVirtualize}
|
||||||
|
contentClassName="perf-contain"
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === 'verified' ? (
|
column.id === 'verified' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -194,7 +404,9 @@ export function KanbanBoard({
|
|||||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||||
title={
|
title={
|
||||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
selectionTarget === 'backlog'
|
||||||
|
? 'Switch to Drag Mode'
|
||||||
|
: 'Select Multiple'
|
||||||
}
|
}
|
||||||
data-testid="selection-mode-button"
|
data-testid="selection-mode-button"
|
||||||
>
|
>
|
||||||
@@ -278,10 +490,16 @@ export function KanbanBoard({
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SortableContext
|
{(() => {
|
||||||
items={columnFeatures.map((f) => f.id)}
|
const reduceEffects = shouldVirtualize;
|
||||||
strategy={verticalListSortingStrategy}
|
const effectiveCardOpacity = reduceEffects
|
||||||
>
|
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
|
||||||
|
: backgroundSettings.cardOpacity;
|
||||||
|
const effectiveGlassmorphism =
|
||||||
|
backgroundSettings.cardGlassmorphism && !reduceEffects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
{/* Empty state card when column has no features */}
|
{/* Empty state card when column has no features */}
|
||||||
{columnFeatures.length === 0 && !isDragging && (
|
{columnFeatures.length === 0 && !isDragging && (
|
||||||
<EmptyStateCard
|
<EmptyStateCard
|
||||||
@@ -290,8 +508,8 @@ export function KanbanBoard({
|
|||||||
addFeatureShortcut={addFeatureShortcut}
|
addFeatureShortcut={addFeatureShortcut}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
customConfig={
|
customConfig={
|
||||||
column.isPipelineStep
|
column.isPipelineStep
|
||||||
? {
|
? {
|
||||||
@@ -302,8 +520,61 @@ export function KanbanBoard({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{columnFeatures.map((feature, index) => {
|
{shouldVirtualize ? (
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
<div className="relative" style={{ height: totalHeight }}>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0"
|
||||||
|
style={{ transform: `translateY(${offsetTop}px)` }}
|
||||||
|
>
|
||||||
|
{visibleItems.map((feature, index) => {
|
||||||
|
const absoluteIndex = startIndex + index;
|
||||||
|
let shortcutKey: string | undefined;
|
||||||
|
if (column.id === 'in_progress' && absoluteIndex < 10) {
|
||||||
|
shortcutKey =
|
||||||
|
absoluteIndex === 9 ? '0' : String(absoluteIndex + 1);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.id}
|
||||||
|
ref={registerItem(feature.id)}
|
||||||
|
style={{ marginBottom: `${KANBAN_CARD_GAP_PX}px` }}
|
||||||
|
>
|
||||||
|
<KanbanCard
|
||||||
|
feature={feature}
|
||||||
|
onEdit={() => onEdit(feature)}
|
||||||
|
onDelete={() => onDelete(feature.id)}
|
||||||
|
onViewOutput={() => onViewOutput(feature)}
|
||||||
|
onVerify={() => onVerify(feature)}
|
||||||
|
onResume={() => onResume(feature)}
|
||||||
|
onForceStop={() => onForceStop(feature)}
|
||||||
|
onManualVerify={() => onManualVerify(feature)}
|
||||||
|
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||||
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
|
onComplete={() => onComplete(feature)}
|
||||||
|
onImplement={() => onImplement(feature)}
|
||||||
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
|
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||||
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
|
shortcutKey={shortcutKey}
|
||||||
|
opacity={effectiveCardOpacity}
|
||||||
|
glassmorphism={effectiveGlassmorphism}
|
||||||
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
|
reduceEffects={reduceEffects}
|
||||||
|
isSelectionMode={isSelectionMode}
|
||||||
|
selectionTarget={selectionTarget}
|
||||||
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
columnFeatures.map((feature, index) => {
|
||||||
let shortcutKey: string | undefined;
|
let shortcutKey: string | undefined;
|
||||||
if (column.id === 'in_progress' && index < 10) {
|
if (column.id === 'in_progress' && index < 10) {
|
||||||
shortcutKey = index === 9 ? '0' : String(index + 1);
|
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||||
@@ -329,19 +600,25 @@ export function KanbanBoard({
|
|||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={effectiveCardOpacity}
|
||||||
glassmorphism={backgroundSettings.cardGlassmorphism}
|
glassmorphism={effectiveGlassmorphism}
|
||||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
|
reduceEffects={reduceEffects}
|
||||||
isSelectionMode={isSelectionMode}
|
isSelectionMode={isSelectionMode}
|
||||||
selectionTarget={selectionTarget}
|
selectionTarget={selectionTarget}
|
||||||
isSelected={selectedFeatureIds.has(feature.id)}
|
isSelected={selectedFeatureIds.has(feature.id)}
|
||||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
|
)}
|
||||||
|
</VirtualizedList>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,7 +658,6 @@ export function KanbanBoard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Eye,
|
Eye,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Sparkles,
|
||||||
Terminal,
|
Terminal,
|
||||||
SquarePlus,
|
SquarePlus,
|
||||||
SplitSquareHorizontal,
|
SplitSquareHorizontal,
|
||||||
Zap,
|
|
||||||
Undo2,
|
Undo2,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
|
hasRemoteBranch: boolean;
|
||||||
isPulling: boolean;
|
isPulling: boolean;
|
||||||
isPushing: boolean;
|
isPushing: boolean;
|
||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
@@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
|
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||||
@@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||||
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
|
||||||
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({
|
|||||||
isSelected,
|
isSelected,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
hasRemoteBranch,
|
||||||
isPulling,
|
isPulling,
|
||||||
isPushing,
|
isPushing,
|
||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
@@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
|
onPushNewBranch,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenInIntegratedTerminal,
|
onOpenInIntegratedTerminal,
|
||||||
onOpenInExternalTerminal,
|
onOpenInExternalTerminal,
|
||||||
@@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
@@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onViewDevServerLogs,
|
onViewDevServerLogs,
|
||||||
onRunInitScript,
|
onRunInitScript,
|
||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
|
onMerge,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
@@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => canPerformGitOps && onPush(worktree)}
|
onClick={() => {
|
||||||
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
if (!canPerformGitOps) return;
|
||||||
|
if (!hasRemoteBranch) {
|
||||||
|
onPushNewBranch(worktree);
|
||||||
|
} else {
|
||||||
|
onPush(worktree);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
|
||||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
{isPushing ? 'Pushing...' : 'Push'}
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||||
{canPerformGitOps && aheadCount > 0 && (
|
{canPerformGitOps && !hasRemoteBranch && (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
|
<Sparkles className="w-2.5 h-2.5" />
|
||||||
|
new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
|
||||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
{aheadCount} ahead
|
{aheadCount} ahead
|
||||||
</span>
|
</span>
|
||||||
@@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({
|
|||||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
{!worktree.isMain && (
|
|
||||||
<TooltipWrapper
|
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
|
||||||
tooltipContent={gitOpsDisabledReason}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => canPerformGitOps && onMerge(worktree)}
|
|
||||||
disabled={!canPerformGitOps}
|
|
||||||
className={cn(
|
|
||||||
'text-xs text-green-600 focus:text-green-700',
|
|
||||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
|
||||||
Merge to Main
|
|
||||||
{!canPerformGitOps && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
{/* Open in editor - split button: click main area for default, chevron for other options */}
|
||||||
{effectiveDefaultEditor && (
|
{effectiveDefaultEditor && (
|
||||||
@@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
<>
|
<>
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => canPerformGitOps && onMerge(worktree)}
|
||||||
|
disabled={!canPerformGitOps}
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-green-600 focus:text-green-700',
|
||||||
|
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Merge Branch
|
||||||
|
{!canPerformGitOps && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDeleteWorktree(worktree)}
|
onClick={() => onDeleteWorktree(worktree)}
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
className="text-xs text-destructive focus:text-destructive"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
@@ -28,6 +29,7 @@ interface WorktreeTabProps {
|
|||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
|
hasRemoteBranch: boolean;
|
||||||
gitRepoStatus: GitRepoStatus;
|
gitRepoStatus: GitRepoStatus;
|
||||||
/** Whether auto mode is running for this worktree */
|
/** Whether auto mode is running for this worktree */
|
||||||
isAutoModeRunning?: boolean;
|
isAutoModeRunning?: boolean;
|
||||||
@@ -39,6 +41,7 @@ interface WorktreeTabProps {
|
|||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
|
onPushNewBranch: (worktree: WorktreeInfo) => void;
|
||||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||||
@@ -79,6 +82,7 @@ export function WorktreeTab({
|
|||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
hasRemoteBranch,
|
||||||
gitRepoStatus,
|
gitRepoStatus,
|
||||||
isAutoModeRunning = false,
|
isAutoModeRunning = false,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
@@ -89,6 +93,7 @@ export function WorktreeTab({
|
|||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
|
onPushNewBranch,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenInIntegratedTerminal,
|
onOpenInIntegratedTerminal,
|
||||||
onOpenInExternalTerminal,
|
onOpenInExternalTerminal,
|
||||||
@@ -108,6 +113,16 @@ export function WorktreeTab({
|
|||||||
onToggleAutoMode,
|
onToggleAutoMode,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
|
// Make the worktree tab a drop target for feature cards
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: `worktree-drop-${worktree.branch}`,
|
||||||
|
data: {
|
||||||
|
type: 'worktree',
|
||||||
|
branch: worktree.branch,
|
||||||
|
path: worktree.path,
|
||||||
|
isMain: worktree.isMain,
|
||||||
|
},
|
||||||
|
});
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: JSX.Element | null = null;
|
||||||
if (worktree.pr) {
|
if (worktree.pr) {
|
||||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||||
@@ -194,7 +209,13 @@ export function WorktreeTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center rounded-md">
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-md transition-all duration-150',
|
||||||
|
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-105'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{worktree.isMain ? (
|
{worktree.isMain ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -366,6 +387,7 @@ export function WorktreeTab({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
@@ -376,6 +398,7 @@ export function WorktreeTab({
|
|||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
|
onPushNewBranch={onPushNewBranch}
|
||||||
onOpenInEditor={onOpenInEditor}
|
onOpenInEditor={onOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||||
|
|||||||
@@ -1,65 +1,46 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import type { EditorInfo } from '@automaker/types';
|
import type { EditorInfo } from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AvailableEditors');
|
|
||||||
|
|
||||||
// Re-export EditorInfo for convenience
|
// Re-export EditorInfo for convenience
|
||||||
export type { EditorInfo };
|
export type { EditorInfo };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and managing available editors
|
||||||
|
*
|
||||||
|
* Uses React Query for data fetching with caching.
|
||||||
|
* Provides a refresh function that clears server cache and re-detects editors.
|
||||||
|
*/
|
||||||
export function useAvailableEditors() {
|
export function useAvailableEditors() {
|
||||||
const [editors, setEditors] = useState<EditorInfo[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const { data: editors = [], isLoading } = useAvailableEditorsQuery();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const fetchAvailableEditors = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.getAvailableEditors) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.getAvailableEditors();
|
|
||||||
if (result.success && result.result?.editors) {
|
|
||||||
setEditors(result.result.editors);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch available editors:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh editors by clearing the server cache and re-detecting
|
* Mutation to refresh editors by clearing the server cache and re-detecting
|
||||||
* Use this when the user has installed/uninstalled editors
|
* Use this when the user has installed/uninstalled editors
|
||||||
*/
|
*/
|
||||||
const refresh = useCallback(async () => {
|
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
|
||||||
setIsRefreshing(true);
|
mutationFn: async () => {
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.refreshEditors) {
|
|
||||||
// Fallback to regular fetch if refresh not available
|
|
||||||
await fetchAvailableEditors();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.refreshEditors();
|
const result = await api.worktree.refreshEditors();
|
||||||
if (result.success && result.result?.editors) {
|
if (!result.success) {
|
||||||
setEditors(result.result.editors);
|
throw new Error(result.error || 'Failed to refresh editors');
|
||||||
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return result.result?.editors ?? [];
|
||||||
logger.error('Failed to refresh editors:', error);
|
},
|
||||||
} finally {
|
onSuccess: (newEditors) => {
|
||||||
setIsRefreshing(false);
|
// Update the cache with new editors
|
||||||
}
|
queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
|
||||||
}, [fetchAvailableEditors]);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const refresh = useCallback(() => {
|
||||||
fetchAvailableEditors();
|
refreshMutate();
|
||||||
}, [fetchAvailableEditors]);
|
}, [refreshMutate]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editors,
|
editors,
|
||||||
|
|||||||
@@ -1,66 +1,46 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useWorktreeBranches } from '@/hooks/queries';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import type { GitRepoStatus } from '../types';
|
||||||
import type { BranchInfo, GitRepoStatus } from '../types';
|
|
||||||
|
|
||||||
const logger = createLogger('Branches');
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing branch data with React Query
|
||||||
|
*
|
||||||
|
* Uses useWorktreeBranches for data fetching while maintaining
|
||||||
|
* the current interface for backward compatibility. Tracks which
|
||||||
|
* worktree path is currently being viewed and fetches branches on demand.
|
||||||
|
*/
|
||||||
export function useBranches() {
|
export function useBranches() {
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
|
||||||
const [aheadCount, setAheadCount] = useState(0);
|
|
||||||
const [behindCount, setBehindCount] = useState(0);
|
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
|
||||||
const [branchFilter, setBranchFilter] = useState('');
|
const [branchFilter, setBranchFilter] = useState('');
|
||||||
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
|
||||||
isGitRepo: true,
|
|
||||||
hasCommits: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Helper to reset branch state to initial values */
|
const {
|
||||||
const resetBranchState = useCallback(() => {
|
data: branchData,
|
||||||
setBranches([]);
|
isLoading: isLoadingBranches,
|
||||||
setAheadCount(0);
|
refetch,
|
||||||
setBehindCount(0);
|
} = useWorktreeBranches(currentWorktreePath);
|
||||||
}, []);
|
|
||||||
|
const branches = branchData?.branches ?? [];
|
||||||
|
const aheadCount = branchData?.aheadCount ?? 0;
|
||||||
|
const behindCount = branchData?.behindCount ?? 0;
|
||||||
|
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
|
||||||
|
// Use conservative defaults (false) until data is confirmed
|
||||||
|
// This prevents the UI from assuming git capabilities before the query completes
|
||||||
|
const gitRepoStatus: GitRepoStatus = {
|
||||||
|
isGitRepo: branchData?.isGitRepo ?? false,
|
||||||
|
hasCommits: branchData?.hasCommits ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBranches = useCallback(
|
const fetchBranches = useCallback(
|
||||||
async (worktreePath: string) => {
|
(worktreePath: string) => {
|
||||||
setIsLoadingBranches(true);
|
if (worktreePath === currentWorktreePath) {
|
||||||
try {
|
// Same path - just refetch to get latest data
|
||||||
const api = getElectronAPI();
|
refetch();
|
||||||
if (!api?.worktree?.listBranches) {
|
} else {
|
||||||
logger.warn('List branches API not available');
|
// Different path - update the tracked path (triggers new query)
|
||||||
return;
|
setCurrentWorktreePath(worktreePath);
|
||||||
}
|
|
||||||
const result = await api.worktree.listBranches(worktreePath);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
setBranches(result.result.branches);
|
|
||||||
setAheadCount(result.result.aheadCount || 0);
|
|
||||||
setBehindCount(result.result.behindCount || 0);
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
|
||||||
} else if (result.code === 'NOT_GIT_REPO') {
|
|
||||||
// Not a git repository - clear branches silently without logging an error
|
|
||||||
resetBranchState();
|
|
||||||
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
|
|
||||||
} else if (result.code === 'NO_COMMITS') {
|
|
||||||
// Git repo but no commits yet - clear branches silently without logging an error
|
|
||||||
resetBranchState();
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
|
|
||||||
} else if (!result.success) {
|
|
||||||
// Other errors - log them
|
|
||||||
logger.warn('Failed to fetch branches:', result.error);
|
|
||||||
resetBranchState();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch branches:', error);
|
|
||||||
resetBranchState();
|
|
||||||
// Reset git status to unknown state on network/API errors
|
|
||||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
|
||||||
} finally {
|
|
||||||
setIsLoadingBranches(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resetBranchState]
|
[currentWorktreePath, refetch]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetBranchFilter = useCallback(() => {
|
const resetBranchFilter = useCallback(() => {
|
||||||
@@ -76,6 +56,7 @@ export function useBranches() {
|
|||||||
filteredBranches,
|
filteredBranches,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
hasRemoteBranch,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
|
|||||||
|
|
||||||
// Match by branchName only (worktreePath is no longer stored)
|
// Match by branchName only (worktreePath is no longer stored)
|
||||||
if (feature.branchName) {
|
if (feature.branchName) {
|
||||||
|
// Special case: if feature is on 'main' branch, it belongs to main worktree
|
||||||
|
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
|
||||||
|
if (worktree.isMain && feature.branchName === 'main') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return worktree.branch === feature.branchName;
|
return worktree.branch === feature.branchName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,128 +3,53 @@ import { useNavigate } from '@tanstack/react-router';
|
|||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useSwitchBranch,
|
||||||
|
usePullWorktree,
|
||||||
|
usePushWorktree,
|
||||||
|
useOpenInEditor,
|
||||||
|
} from '@/hooks/mutations';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
const logger = createLogger('WorktreeActions');
|
const logger = createLogger('WorktreeActions');
|
||||||
|
|
||||||
// Error codes that need special user-friendly handling
|
export function useWorktreeActions() {
|
||||||
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
|
|
||||||
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
|
|
||||||
|
|
||||||
// User-friendly messages for git status errors
|
|
||||||
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
|
|
||||||
NOT_GIT_REPO: 'This directory is not a git repository',
|
|
||||||
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to handle git status errors with user-friendly messages.
|
|
||||||
* @returns true if the error was a git status error and was handled, false otherwise.
|
|
||||||
*/
|
|
||||||
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
|
|
||||||
const errorCode = result.code as GitStatusErrorCode | undefined;
|
|
||||||
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
|
|
||||||
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseWorktreeActionsOptions {
|
|
||||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
|
||||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isPulling, setIsPulling] = useState(false);
|
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
|
|
||||||
|
// Use React Query mutations
|
||||||
|
const switchBranchMutation = useSwitchBranch();
|
||||||
|
const pullMutation = usePullWorktree();
|
||||||
|
const pushMutation = usePushWorktree();
|
||||||
|
const openInEditorMutation = useOpenInEditor();
|
||||||
|
|
||||||
const handleSwitchBranch = useCallback(
|
const handleSwitchBranch = useCallback(
|
||||||
async (worktree: WorktreeInfo, branchName: string) => {
|
async (worktree: WorktreeInfo, branchName: string) => {
|
||||||
if (isSwitching || branchName === worktree.branch) return;
|
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
|
||||||
setIsSwitching(true);
|
switchBranchMutation.mutate({
|
||||||
try {
|
worktreePath: worktree.path,
|
||||||
const api = getElectronAPI();
|
branchName,
|
||||||
if (!api?.worktree?.switchBranch) {
|
});
|
||||||
toast.error('Switch branch API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.switchBranch(worktree.path, branchName);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to switch branch');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Switch branch failed:', error);
|
|
||||||
toast.error('Failed to switch branch');
|
|
||||||
} finally {
|
|
||||||
setIsSwitching(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isSwitching, fetchWorktrees]
|
[switchBranchMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePull = useCallback(
|
const handlePull = useCallback(
|
||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
if (isPulling) return;
|
if (pullMutation.isPending) return;
|
||||||
setIsPulling(true);
|
pullMutation.mutate(worktree.path);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.pull) {
|
|
||||||
toast.error('Pull API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.pull(worktree.path);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to pull latest changes');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Pull failed:', error);
|
|
||||||
toast.error('Failed to pull latest changes');
|
|
||||||
} finally {
|
|
||||||
setIsPulling(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPulling, fetchWorktrees]
|
[pullMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePush = useCallback(
|
const handlePush = useCallback(
|
||||||
async (worktree: WorktreeInfo) => {
|
async (worktree: WorktreeInfo) => {
|
||||||
if (isPushing) return;
|
if (pushMutation.isPending) return;
|
||||||
setIsPushing(true);
|
pushMutation.mutate({
|
||||||
try {
|
worktreePath: worktree.path,
|
||||||
const api = getElectronAPI();
|
});
|
||||||
if (!api?.worktree?.push) {
|
|
||||||
toast.error('Push API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.worktree.push(worktree.path);
|
|
||||||
if (result.success && result.result) {
|
|
||||||
toast.success(result.result.message);
|
|
||||||
fetchBranches(worktree.path);
|
|
||||||
fetchWorktrees();
|
|
||||||
} else {
|
|
||||||
if (handleGitStatusError(result)) return;
|
|
||||||
toast.error(result.error || 'Failed to push changes');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Push failed:', error);
|
|
||||||
toast.error('Failed to push changes');
|
|
||||||
} finally {
|
|
||||||
setIsPushing(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPushing, fetchBranches, fetchWorktrees]
|
[pushMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInIntegratedTerminal = useCallback(
|
const handleOpenInIntegratedTerminal = useCallback(
|
||||||
@@ -140,23 +65,15 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
|
const handleOpenInEditor = useCallback(
|
||||||
try {
|
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
const api = getElectronAPI();
|
openInEditorMutation.mutate({
|
||||||
if (!api?.worktree?.openInEditor) {
|
worktreePath: worktree.path,
|
||||||
logger.warn('Open in editor API not available');
|
editorCommand,
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
|
[openInEditorMutation]
|
||||||
if (result.success && result.result) {
|
);
|
||||||
toast.success(result.result.message);
|
|
||||||
} else if (result.error) {
|
|
||||||
toast.error(result.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Open in editor failed:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenInExternalTerminal = useCallback(
|
const handleOpenInExternalTerminal = useCallback(
|
||||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||||
@@ -180,9 +97,9 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPulling,
|
isPulling: pullMutation.isPending,
|
||||||
isPushing,
|
isPushing: pushMutation.isPending,
|
||||||
isSwitching,
|
isSwitching: switchBranchMutation.isPending,
|
||||||
isActivating,
|
isActivating,
|
||||||
setIsActivating,
|
setIsActivating,
|
||||||
handleSwitchBranch,
|
handleSwitchBranch,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useWorktrees as useWorktreesQuery } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import type { WorktreeInfo } from '../types';
|
import type { WorktreeInfo } from '../types';
|
||||||
|
|
||||||
const logger = createLogger('Worktrees');
|
|
||||||
|
|
||||||
interface UseWorktreesOptions {
|
interface UseWorktreesOptions {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
@@ -18,62 +17,46 @@ export function useWorktrees({
|
|||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
}: UseWorktreesOptions) {
|
}: UseWorktreesOptions) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
|
||||||
|
|
||||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||||
|
|
||||||
const fetchWorktrees = useCallback(
|
// Use the React Query hook
|
||||||
async (options?: { silent?: boolean }) => {
|
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
|
||||||
if (!projectPath) return;
|
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
|
||||||
const silent = options?.silent ?? false;
|
|
||||||
if (!silent) {
|
|
||||||
setIsLoading(true);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api?.worktree?.listAll) {
|
|
||||||
logger.warn('Worktree API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Pass forceRefreshGitHub when this is a manual refresh (not silent polling)
|
|
||||||
// This clears the GitHub remote cache so users can re-detect after adding a remote
|
|
||||||
const forceRefreshGitHub = !silent;
|
|
||||||
const result = await api.worktree.listAll(projectPath, true, forceRefreshGitHub);
|
|
||||||
if (result.success && result.worktrees) {
|
|
||||||
setWorktrees(result.worktrees);
|
|
||||||
setWorktreesInStore(projectPath, result.worktrees);
|
|
||||||
}
|
|
||||||
// Return removed worktrees so they can be handled by the caller
|
|
||||||
return result.removedWorktrees;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch worktrees:', error);
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
if (!silent) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectPath, setWorktreesInStore]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Sync worktrees to Zustand store when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorktrees();
|
if (worktrees.length > 0) {
|
||||||
}, [fetchWorktrees]);
|
setWorktreesInStore(projectPath, worktrees);
|
||||||
|
}
|
||||||
|
}, [worktrees, projectPath, setWorktreesInStore]);
|
||||||
|
|
||||||
|
// Handle removed worktrees callback when data changes
|
||||||
|
const prevRemovedWorktreesRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.removedWorktrees && data.removedWorktrees.length > 0) {
|
||||||
|
// Create a stable key to avoid duplicate callbacks
|
||||||
|
const key = JSON.stringify(data.removedWorktrees);
|
||||||
|
if (key !== prevRemovedWorktreesRef.current) {
|
||||||
|
prevRemovedWorktreesRef.current = key;
|
||||||
|
onRemovedWorktrees?.(data.removedWorktrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data?.removedWorktrees, onRemovedWorktrees]);
|
||||||
|
|
||||||
|
// Handle refresh trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (refreshTrigger > 0) {
|
if (refreshTrigger > 0) {
|
||||||
fetchWorktrees().then((removedWorktrees) => {
|
// Invalidate and refetch to get fresh data including any removed worktrees
|
||||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
queryClient.invalidateQueries({
|
||||||
onRemovedWorktrees(removedWorktrees);
|
queryKey: queryKeys.worktrees.all(projectPath),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
}, [refreshTrigger, projectPath, queryClient]);
|
||||||
|
|
||||||
// Use a ref to track the current worktree to avoid running validation
|
// Use a ref to track the current worktree to avoid running validation
|
||||||
// when selection changes (which could cause a race condition with stale worktrees list)
|
// when selection changes (which could cause a race condition with stale worktrees list)
|
||||||
@@ -111,6 +94,14 @@ export function useWorktrees({
|
|||||||
[projectPath, setCurrentWorktree]
|
[projectPath, setCurrentWorktree]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
|
const fetchWorktrees = useCallback(async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.worktrees.all(projectPath),
|
||||||
|
});
|
||||||
|
return refetch();
|
||||||
|
}, [projectPath, queryClient, refetch]);
|
||||||
|
|
||||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
const selectedWorktree = currentWorktreePath
|
const selectedWorktree = currentWorktreePath
|
||||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export interface PRInfo {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MergeConflictInfo {
|
||||||
|
sourceBranch: string;
|
||||||
|
targetBranch: string;
|
||||||
|
targetWorktreePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorktreePanelProps {
|
export interface WorktreePanelProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCreateWorktree: () => void;
|
onCreateWorktree: () => void;
|
||||||
@@ -70,7 +76,9 @@ export interface WorktreePanelProps {
|
|||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
|
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||||
|
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||||
runningFeatureIds?: string[];
|
runningFeatureIds?: string[];
|
||||||
features?: FeatureInfo[];
|
features?: FeatureInfo[];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { pathsEqual } from '@/lib/utils';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
|
import { useWorktreeInitScript } from '@/hooks/queries';
|
||||||
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
import type { WorktreePanelProps, WorktreeInfo } from './types';
|
||||||
import {
|
import {
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
@@ -22,9 +23,10 @@ import {
|
|||||||
BranchSwitchDropdown,
|
BranchSwitchDropdown,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ViewWorktreeChangesDialog } from '../dialogs';
|
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import { Undo2 } from 'lucide-react';
|
import { Undo2 } from 'lucide-react';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
|
||||||
export function WorktreePanel({
|
export function WorktreePanel({
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -35,7 +37,8 @@ export function WorktreePanel({
|
|||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onCreateMergeConflictResolutionFeature,
|
||||||
|
onBranchDeletedDuringMerge,
|
||||||
onRemovedWorktrees,
|
onRemovedWorktrees,
|
||||||
runningFeatureIds = [],
|
runningFeatureIds = [],
|
||||||
features = [],
|
features = [],
|
||||||
@@ -66,6 +69,7 @@ export function WorktreePanel({
|
|||||||
filteredBranches,
|
filteredBranches,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
hasRemoteBranch,
|
||||||
isLoadingBranches,
|
isLoadingBranches,
|
||||||
branchFilter,
|
branchFilter,
|
||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
@@ -85,10 +89,7 @@ export function WorktreePanel({
|
|||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
} = useWorktreeActions({
|
} = useWorktreeActions();
|
||||||
fetchWorktrees,
|
|
||||||
fetchBranches,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { hasRunningFeatures } = useRunningFeatures({
|
const { hasRunningFeatures } = useRunningFeatures({
|
||||||
runningFeatureIds,
|
runningFeatureIds,
|
||||||
@@ -156,8 +157,9 @@ export function WorktreePanel({
|
|||||||
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
[currentProject, projectPath, isAutoModeRunningForWorktree]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track whether init script exists for the project
|
// Check if init script exists for the project using React Query
|
||||||
const [hasInitScript, setHasInitScript] = useState(false);
|
const { data: initScriptData } = useWorktreeInitScript(projectPath);
|
||||||
|
const hasInitScript = initScriptData?.exists ?? false;
|
||||||
|
|
||||||
// View changes dialog state
|
// View changes dialog state
|
||||||
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
|
||||||
@@ -171,24 +173,13 @@ export function WorktreePanel({
|
|||||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Push to remote dialog state
|
||||||
if (!projectPath) {
|
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
|
||||||
setHasInitScript(false);
|
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkInitScript = async () => {
|
// Merge branch dialog state
|
||||||
try {
|
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||||
const api = getHttpApiClient();
|
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
|
||||||
const result = await api.worktree.getInitScript(projectPath);
|
|
||||||
setHasInitScript(result.success && result.exists);
|
|
||||||
} catch {
|
|
||||||
setHasInitScript(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkInitScript();
|
|
||||||
}, [projectPath]);
|
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -300,6 +291,54 @@ export function WorktreePanel({
|
|||||||
// Keep logPanelWorktree set for smooth close animation
|
// Keep logPanelWorktree set for smooth close animation
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle opening the push to remote dialog
|
||||||
|
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||||
|
setPushToRemoteWorktree(worktree);
|
||||||
|
setPushToRemoteDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle confirming the push to remote dialog
|
||||||
|
const handleConfirmPushToRemote = useCallback(
|
||||||
|
async (worktree: WorktreeInfo, remote: string) => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.push) {
|
||||||
|
toast.error('Push API not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.worktree.push(worktree.path, false, remote);
|
||||||
|
if (result.success && result.result) {
|
||||||
|
toast.success(result.result.message);
|
||||||
|
fetchBranches(worktree.path);
|
||||||
|
fetchWorktrees();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to push changes');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to push changes');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchBranches, fetchWorktrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle opening the merge dialog
|
||||||
|
const handleMerge = useCallback((worktree: WorktreeInfo) => {
|
||||||
|
setMergeWorktree(worktree);
|
||||||
|
setMergeDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
|
||||||
|
const handleMerged = useCallback(
|
||||||
|
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
|
||||||
|
fetchWorktrees();
|
||||||
|
// If the branch was deleted, notify parent to reassign features to main
|
||||||
|
if (deletedBranch && onBranchDeletedDuringMerge) {
|
||||||
|
onBranchDeletedDuringMerge(mergedWorktree.branch);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchWorktrees, onBranchDeletedDuringMerge]
|
||||||
|
);
|
||||||
|
|
||||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||||
|
|
||||||
@@ -345,6 +384,7 @@ export function WorktreePanel({
|
|||||||
standalone={true}
|
standalone={true}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
@@ -355,6 +395,7 @@ export function WorktreePanel({
|
|||||||
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -364,7 +405,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
@@ -435,6 +476,24 @@ export function WorktreePanel({
|
|||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Push to Remote Dialog */}
|
||||||
|
<PushToRemoteDialog
|
||||||
|
open={pushToRemoteDialogOpen}
|
||||||
|
onOpenChange={setPushToRemoteDialogOpen}
|
||||||
|
worktree={pushToRemoteWorktree}
|
||||||
|
onConfirm={handleConfirmPushToRemote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Merge Branch Dialog */}
|
||||||
|
<MergeWorktreeDialog
|
||||||
|
open={mergeDialogOpen}
|
||||||
|
onOpenChange={setMergeDialogOpen}
|
||||||
|
projectPath={projectPath}
|
||||||
|
worktree={mergeWorktree}
|
||||||
|
onMerged={handleMerged}
|
||||||
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,6 +527,7 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
@@ -478,6 +538,7 @@ export function WorktreePanel({
|
|||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -487,7 +548,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
@@ -532,6 +593,7 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
hasRemoteBranch={hasRemoteBranch}
|
||||||
gitRepoStatus={gitRepoStatus}
|
gitRepoStatus={gitRepoStatus}
|
||||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
@@ -542,6 +604,7 @@ export function WorktreePanel({
|
|||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onPull={handlePull}
|
onPull={handlePull}
|
||||||
onPush={handlePush}
|
onPush={handlePush}
|
||||||
|
onPushNewBranch={handlePushNewBranch}
|
||||||
onOpenInEditor={handleOpenInEditor}
|
onOpenInEditor={handleOpenInEditor}
|
||||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||||
@@ -551,7 +614,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
@@ -622,6 +685,24 @@ export function WorktreePanel({
|
|||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Push to Remote Dialog */}
|
||||||
|
<PushToRemoteDialog
|
||||||
|
open={pushToRemoteDialogOpen}
|
||||||
|
onOpenChange={setPushToRemoteDialogOpen}
|
||||||
|
worktree={pushToRemoteWorktree}
|
||||||
|
onConfirm={handleConfirmPushToRemote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Merge Branch Dialog */}
|
||||||
|
<MergeWorktreeDialog
|
||||||
|
open={mergeDialogOpen}
|
||||||
|
onOpenChange={setMergeDialogOpen}
|
||||||
|
projectPath={projectPath}
|
||||||
|
worktree={mergeWorktree}
|
||||||
|
onMerged={handleMerged}
|
||||||
|
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { UIEvent } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
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';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +24,10 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
const CHAT_SESSION_ROW_HEIGHT_PX = 84;
|
||||||
|
const CHAT_SESSION_OVERSCAN_COUNT = 6;
|
||||||
|
const CHAT_SESSION_LIST_PADDING_PX = 8;
|
||||||
|
|
||||||
export function ChatHistory() {
|
export function ChatHistory() {
|
||||||
const {
|
const {
|
||||||
chatSessions,
|
chatSessions,
|
||||||
@@ -34,29 +40,117 @@ export function ChatHistory() {
|
|||||||
unarchiveChatSession,
|
unarchiveChatSession,
|
||||||
deleteChatSession,
|
deleteChatSession,
|
||||||
setChatHistoryOpen,
|
setChatHistoryOpen,
|
||||||
} = useAppStore();
|
} = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
chatSessions: state.chatSessions,
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
currentChatSession: state.currentChatSession,
|
||||||
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
|
createChatSession: state.createChatSession,
|
||||||
|
setCurrentChatSession: state.setCurrentChatSession,
|
||||||
|
archiveChatSession: state.archiveChatSession,
|
||||||
|
unarchiveChatSession: state.unarchiveChatSession,
|
||||||
|
deleteChatSession: state.deleteChatSession,
|
||||||
|
setChatHistoryOpen: state.setChatHistoryOpen,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
|
|
||||||
if (!currentProject) {
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
return null;
|
const currentProjectId = currentProject?.id;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter sessions for current project
|
// Filter sessions for current project
|
||||||
const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id);
|
const projectSessions = useMemo(() => {
|
||||||
|
if (!currentProjectId) return [];
|
||||||
|
return chatSessions.filter((session) => session.projectId === currentProjectId);
|
||||||
|
}, [chatSessions, currentProjectId]);
|
||||||
|
|
||||||
// Filter by search query and archived status
|
// Filter by search query and archived status
|
||||||
const filteredSessions = projectSessions.filter((session) => {
|
const filteredSessions = useMemo(() => {
|
||||||
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
|
return projectSessions.filter((session) => {
|
||||||
|
const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
|
||||||
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
|
||||||
return matchesSearch && matchesArchivedStatus;
|
return matchesSearch && matchesArchivedStatus;
|
||||||
});
|
});
|
||||||
|
}, [projectSessions, normalizedQuery, showArchived]);
|
||||||
|
|
||||||
// Sort by most recently updated
|
// Sort by most recently updated
|
||||||
const sortedSessions = filteredSessions.sort(
|
const sortedSessions = useMemo(() => {
|
||||||
|
return [...filteredSessions].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
);
|
);
|
||||||
|
}, [filteredSessions]);
|
||||||
|
|
||||||
|
const totalHeight =
|
||||||
|
sortedSessions.length * CHAT_SESSION_ROW_HEIGHT_PX + CHAT_SESSION_LIST_PADDING_PX * 2;
|
||||||
|
const startIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(scrollTop / CHAT_SESSION_ROW_HEIGHT_PX) - CHAT_SESSION_OVERSCAN_COUNT
|
||||||
|
);
|
||||||
|
const endIndex = Math.min(
|
||||||
|
sortedSessions.length,
|
||||||
|
Math.ceil((scrollTop + viewportHeight) / CHAT_SESSION_ROW_HEIGHT_PX) +
|
||||||
|
CHAT_SESSION_OVERSCAN_COUNT
|
||||||
|
);
|
||||||
|
const offsetTop = startIndex * CHAT_SESSION_ROW_HEIGHT_PX;
|
||||||
|
const visibleSessions = sortedSessions.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (scrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
}
|
||||||
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
|
setScrollTop(target.scrollTop);
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = listRef.current;
|
||||||
|
if (!container || typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const updateHeight = () => {
|
||||||
|
setViewportHeight(container.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeight();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', updateHeight);
|
||||||
|
return () => window.removeEventListener('resize', updateHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => updateHeight());
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [chatHistoryOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatHistoryOpen) return;
|
||||||
|
setScrollTop(0);
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [chatHistoryOpen, normalizedQuery, showArchived, currentProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scrollRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!currentProjectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateNewChat = () => {
|
const handleCreateNewChat = () => {
|
||||||
createChatSession();
|
createChatSession();
|
||||||
@@ -151,7 +245,11 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Sessions List */}
|
{/* Chat Sessions List */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div
|
||||||
|
className="flex-1 overflow-y-auto perf-contain"
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
{sortedSessions.length === 0 ? (
|
{sortedSessions.length === 0 ? (
|
||||||
<div className="p-4 text-center text-muted-foreground">
|
<div className="p-4 text-center text-muted-foreground">
|
||||||
{searchQuery ? (
|
{searchQuery ? (
|
||||||
@@ -163,14 +261,26 @@ export function ChatHistory() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div
|
||||||
{sortedSessions.map((session) => (
|
className="relative"
|
||||||
|
style={{
|
||||||
|
height: totalHeight,
|
||||||
|
paddingTop: CHAT_SESSION_LIST_PADDING_PX,
|
||||||
|
paddingBottom: CHAT_SESSION_LIST_PADDING_PX,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0"
|
||||||
|
style={{ transform: `translateY(${offsetTop}px)` }}
|
||||||
|
>
|
||||||
|
{visibleSessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
|
||||||
currentChatSession?.id === session.id && 'bg-accent'
|
currentChatSession?.id === session.id && 'bg-accent'
|
||||||
)}
|
)}
|
||||||
|
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
|
||||||
onClick={() => handleSelectSession(session)}
|
onClick={() => handleSelectSession(session)}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -199,7 +309,9 @@ export function ChatHistory() {
|
|||||||
Unarchive
|
Unarchive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => handleArchiveSession(session.id, e)}
|
||||||
|
>
|
||||||
<Archive className="w-4 h-4 mr-2" />
|
<Archive className="w-4 h-4 mr-2" />
|
||||||
Archive
|
Archive
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -218,6 +330,7 @@ export function ChatHistory() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,6 +11,7 @@ import { LoadingState } from '@/components/ui/loading-state';
|
|||||||
import { ErrorState } from '@/components/ui/error-state';
|
import { ErrorState } from '@/components/ui/error-state';
|
||||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
@@ -36,6 +38,7 @@ export function GitHubIssuesView() {
|
|||||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
|
||||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Model override for validation
|
// Model override for validation
|
||||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
@@ -153,6 +156,10 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
const result = await api.features.create(currentProject.path, feature);
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Invalidate React Query cache to sync UI
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
toast.success(`Created task: ${issue.title}`);
|
toast.success(`Created task: ${issue.title}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to create task');
|
toast.error(result.error || 'Failed to create task');
|
||||||
@@ -163,7 +170,7 @@ export function GitHubIssuesView() {
|
|||||||
toast.error(err instanceof Error ? err.message : 'Failed to create task');
|
toast.error(err instanceof Error ? err.message : 'Failed to create task');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentProject?.path, currentBranch]
|
[currentProject?.path, currentBranch, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -1,79 +1,29 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
/**
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
* GitHub Issues Hook
|
||||||
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
|
*
|
||||||
|
* React Query-based hook for fetching GitHub issues.
|
||||||
|
*/
|
||||||
|
|
||||||
const logger = createLogger('GitHubIssues');
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries';
|
||||||
|
|
||||||
export function useGithubIssues() {
|
export function useGithubIssues() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
const [openIssues, setOpenIssues] = useState<GitHubIssue[]>([]);
|
|
||||||
const [closedIssues, setClosedIssues] = useState<GitHubIssue[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
const fetchIssues = useCallback(async () => {
|
const {
|
||||||
if (!currentProject?.path) {
|
data,
|
||||||
if (isMountedRef.current) {
|
isLoading: loading,
|
||||||
setError('No project selected');
|
isFetching: refreshing,
|
||||||
setLoading(false);
|
error,
|
||||||
}
|
refetch: refresh,
|
||||||
return;
|
} = useGitHubIssuesQuery(currentProject?.path);
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github) {
|
|
||||||
const result = await api.github.listIssues(currentProject.path);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
if (result.success) {
|
|
||||||
setOpenIssues(result.openIssues || []);
|
|
||||||
setClosedIssues(result.closedIssues || []);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to fetch issues');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
logger.error('Error fetching issues:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch issues');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
fetchIssues();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [fetchIssues]);
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setRefreshing(true);
|
|
||||||
}
|
|
||||||
fetchIssues();
|
|
||||||
}, [fetchIssues]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openIssues,
|
openIssues: data?.openIssues ?? [],
|
||||||
closedIssues,
|
closedIssues: data?.closedIssues ?? [],
|
||||||
loading,
|
loading,
|
||||||
refreshing,
|
refreshing,
|
||||||
error,
|
error: error instanceof Error ? error.message : error ? String(error) : null,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import type { GitHubComment } from '@/lib/electron';
|
||||||
import { getElectronAPI, GitHubComment } from '@/lib/electron';
|
|
||||||
|
|
||||||
const logger = createLogger('IssueComments');
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useGitHubIssueComments } from '@/hooks/queries';
|
||||||
|
|
||||||
interface UseIssueCommentsResult {
|
interface UseIssueCommentsResult {
|
||||||
comments: GitHubComment[];
|
comments: GitHubComment[];
|
||||||
@@ -18,119 +16,36 @@ interface UseIssueCommentsResult {
|
|||||||
|
|
||||||
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
const [comments, setComments] = useState<GitHubComment[]>([]);
|
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
const fetchComments = useCallback(
|
// Use React Query infinite query
|
||||||
async (cursor?: string) => {
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } =
|
||||||
if (!currentProject?.path || !issueNumber) {
|
useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoadingMore = !!cursor;
|
// Flatten all pages into a single comments array
|
||||||
|
const comments = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.comments) ?? [];
|
||||||
|
}, [data?.pages]);
|
||||||
|
|
||||||
try {
|
// Get total count from the first page
|
||||||
if (isMountedRef.current) {
|
const totalCount = data?.pages[0]?.totalCount ?? 0;
|
||||||
setError(null);
|
|
||||||
if (isLoadingMore) {
|
|
||||||
setLoadingMore(true);
|
|
||||||
} else {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github) {
|
|
||||||
const result = await api.github.getIssueComments(
|
|
||||||
currentProject.path,
|
|
||||||
issueNumber,
|
|
||||||
cursor
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
if (result.success) {
|
|
||||||
if (isLoadingMore) {
|
|
||||||
// Append new comments
|
|
||||||
setComments((prev) => [...prev, ...(result.comments || [])]);
|
|
||||||
} else {
|
|
||||||
// Replace all comments
|
|
||||||
setComments(result.comments || []);
|
|
||||||
}
|
|
||||||
setTotalCount(result.totalCount || 0);
|
|
||||||
setHasNextPage(result.hasNextPage || false);
|
|
||||||
setEndCursor(result.endCursor);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to fetch comments');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
logger.error('Error fetching comments:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentProject?.path, issueNumber]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset and fetch when issue changes
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
|
|
||||||
if (issueNumber) {
|
|
||||||
// Reset state when issue changes
|
|
||||||
setComments([]);
|
|
||||||
setTotalCount(0);
|
|
||||||
setHasNextPage(false);
|
|
||||||
setEndCursor(undefined);
|
|
||||||
setError(null);
|
|
||||||
fetchComments();
|
|
||||||
} else {
|
|
||||||
// Clear comments when no issue is selected
|
|
||||||
setComments([]);
|
|
||||||
setTotalCount(0);
|
|
||||||
setHasNextPage(false);
|
|
||||||
setEndCursor(undefined);
|
|
||||||
setLoading(false);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [issueNumber, fetchComments]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (hasNextPage && endCursor && !loadingMore) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchComments(endCursor);
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setComments([]);
|
refetch();
|
||||||
setEndCursor(undefined);
|
}, [refetch]);
|
||||||
fetchComments();
|
|
||||||
}, [fetchComments]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
comments,
|
comments,
|
||||||
totalCount,
|
totalCount,
|
||||||
loading,
|
loading: isLoading,
|
||||||
loadingMore,
|
loadingMore: isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage: hasNextPage ?? false,
|
||||||
error,
|
error: error instanceof Error ? error.message : null,
|
||||||
loadMore,
|
loadMore,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { isValidationStale } from '../utils';
|
import { isValidationStale } from '../utils';
|
||||||
|
import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
|
||||||
|
|
||||||
const logger = createLogger('IssueValidation');
|
const logger = createLogger('IssueValidation');
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ export function useIssueValidation({
|
|||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
// React Query mutations
|
||||||
|
const validateIssueMutation = useValidateIssue(currentProject?.path ?? '');
|
||||||
|
const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? '');
|
||||||
// Refs for stable event handler (avoids re-subscribing on state changes)
|
// Refs for stable event handler (avoids re-subscribing on state changes)
|
||||||
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
const selectedIssueRef = useRef<GitHubIssue | null>(null);
|
||||||
const showValidationDialogRef = useRef(false);
|
const showValidationDialogRef = useRef(false);
|
||||||
@@ -240,7 +245,7 @@ export function useIssueValidation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if already validating this issue
|
// Check if already validating this issue
|
||||||
if (validatingIssues.has(issue.number)) {
|
if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) {
|
||||||
toast.info(`Validation already in progress for issue #${issue.number}`);
|
toast.info(`Validation already in progress for issue #${issue.number}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -254,11 +259,6 @@ export function useIssueValidation({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start async validation in background (no dialog - user will see badge when done)
|
|
||||||
toast.info(`Starting validation for issue #${issue.number}`, {
|
|
||||||
description: 'You will be notified when the analysis is complete',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use provided model override or fall back to phaseModels.validationModel
|
// Use provided model override or fall back to phaseModels.validationModel
|
||||||
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
|
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
|
||||||
const effectiveModelEntry = modelEntry
|
const effectiveModelEntry = modelEntry
|
||||||
@@ -276,40 +276,22 @@ export function useIssueValidation({
|
|||||||
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
|
||||||
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
|
||||||
|
|
||||||
try {
|
// Use mutation to trigger validation (toast is handled by mutation)
|
||||||
const api = getElectronAPI();
|
validateIssueMutation.mutate({
|
||||||
if (api.github?.validateIssue) {
|
issue,
|
||||||
const validationInput = {
|
model: modelToUse,
|
||||||
issueNumber: issue.number,
|
thinkingLevel: thinkingLevelToUse,
|
||||||
issueTitle: issue.title,
|
reasoningEffort: reasoningEffortToUse,
|
||||||
issueBody: issue.body || '',
|
comments,
|
||||||
issueLabels: issue.labels.map((l) => l.name),
|
linkedPRs,
|
||||||
comments, // Include comments if provided
|
});
|
||||||
linkedPRs, // Include linked PRs if provided
|
|
||||||
};
|
|
||||||
const result = await api.github.validateIssue(
|
|
||||||
currentProject.path,
|
|
||||||
validationInput,
|
|
||||||
modelToUse,
|
|
||||||
thinkingLevelToUse,
|
|
||||||
reasoningEffortToUse
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error || 'Failed to start validation');
|
|
||||||
}
|
|
||||||
// On success, the result will come through the event stream
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Validation error:', err);
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
validatingIssues,
|
validatingIssues,
|
||||||
cachedValidations,
|
cachedValidations,
|
||||||
phaseModels.validationModel,
|
phaseModels.validationModel,
|
||||||
|
validateIssueMutation,
|
||||||
onValidationResultChange,
|
onValidationResultChange,
|
||||||
onShowValidationDialogChange,
|
onShowValidationDialogChange,
|
||||||
]
|
]
|
||||||
@@ -325,10 +307,8 @@ export function useIssueValidation({
|
|||||||
|
|
||||||
// Mark as viewed if not already viewed
|
// Mark as viewed if not already viewed
|
||||||
if (!cached.viewedAt && currentProject?.path) {
|
if (!cached.viewedAt && currentProject?.path) {
|
||||||
try {
|
markViewedMutation.mutate(issue.number, {
|
||||||
const api = getElectronAPI();
|
onSuccess: () => {
|
||||||
if (api.github?.markValidationViewed) {
|
|
||||||
await api.github.markValidationViewed(currentProject.path, issue.number);
|
|
||||||
// Update local state
|
// Update local state
|
||||||
setCachedValidations((prev) => {
|
setCachedValidations((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -341,16 +321,15 @@ export function useIssueValidation({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
} catch (err) {
|
});
|
||||||
logger.error('Failed to mark validation as viewed:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
cachedValidations,
|
cachedValidations,
|
||||||
currentProject?.path,
|
currentProject?.path,
|
||||||
|
markViewedMutation,
|
||||||
onValidationResultChange,
|
onValidationResultChange,
|
||||||
onShowValidationDialogChange,
|
onShowValidationDialogChange,
|
||||||
]
|
]
|
||||||
@@ -361,5 +340,6 @@ export function useIssueValidation({
|
|||||||
cachedValidations,
|
cachedValidations,
|
||||||
handleValidateIssue,
|
handleValidateIssue,
|
||||||
handleViewCachedValidation,
|
handleViewCachedValidation,
|
||||||
|
isValidating: validateIssueMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,37 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
/**
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
* GitHub PRs View
|
||||||
|
*
|
||||||
|
* Displays pull requests using React Query for data fetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI, GitHubPR } from '@/lib/electron';
|
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGitHubPRs } from '@/hooks/queries';
|
||||||
const logger = createLogger('GitHubPRsView');
|
|
||||||
|
|
||||||
export function GitHubPRsView() {
|
export function GitHubPRsView() {
|
||||||
const [openPRs, setOpenPRs] = useState<GitHubPR[]>([]);
|
|
||||||
const [mergedPRs, setMergedPRs] = useState<GitHubPR[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
const fetchPRs = useCallback(async () => {
|
const {
|
||||||
if (!currentProject?.path) {
|
data,
|
||||||
setError('No project selected');
|
isLoading: loading,
|
||||||
setLoading(false);
|
isFetching: refreshing,
|
||||||
return;
|
error,
|
||||||
}
|
refetch,
|
||||||
|
} = useGitHubPRs(currentProject?.path);
|
||||||
|
|
||||||
try {
|
const openPRs = data?.openPRs ?? [];
|
||||||
setError(null);
|
const mergedPRs = data?.mergedPRs ?? [];
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.github) {
|
|
||||||
const result = await api.github.listPRs(currentProject.path);
|
|
||||||
if (result.success) {
|
|
||||||
setOpenPRs(result.openPRs || []);
|
|
||||||
setMergedPRs(result.mergedPRs || []);
|
|
||||||
} else {
|
|
||||||
setError(result.error || 'Failed to fetch pull requests');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error fetching PRs:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch pull requests');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPRs();
|
|
||||||
}, [fetchPRs]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
refetch();
|
||||||
fetchPRs();
|
}, [refetch]);
|
||||||
}, [fetchPRs]);
|
|
||||||
|
|
||||||
const handleOpenInGitHub = useCallback((url: string) => {
|
const handleOpenInGitHub = useCallback((url: string) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -99,7 +76,9 @@ export function GitHubPRsView() {
|
|||||||
<GitPullRequest className="h-12 w-12 text-destructive" />
|
<GitPullRequest className="h-12 w-12 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
|
<h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2>
|
||||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
<p className="text-muted-foreground max-w-md mb-4">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to fetch pull requests'}
|
||||||
|
</p>
|
||||||
<Button variant="outline" onClick={handleRefresh}>
|
<Button variant="outline" onClick={handleRefresh}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Try Again
|
Try Again
|
||||||
@@ -197,9 +176,9 @@ export function GitHubPRsView() {
|
|||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{selectedPR.state === 'MERGED' ? (
|
{selectedPR.state === 'MERGED' ? (
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 flex-shrink-0" />
|
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<GitPullRequest className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium truncate">
|
<span className="text-sm font-medium truncate">
|
||||||
#{selectedPR.number} {selectedPR.title}
|
#{selectedPR.number} {selectedPR.title}
|
||||||
@@ -210,7 +189,7 @@ export function GitHubPRsView() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -342,16 +321,16 @@ function PRRow({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{pr.state === 'MERGED' ? (
|
{pr.state === 'MERGED' ? (
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
<GitMerge className="h-4 w-4 text-purple-500 mt-0.5 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
<GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium truncate">{pr.title}</span>
|
<span className="text-sm font-medium truncate">{pr.title}</span>
|
||||||
{pr.isDraft && (
|
{pr.isDraft && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground flex-shrink-0">
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground shrink-0">
|
||||||
Draft
|
Draft
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -402,7 +381,7 @@ function PRRow({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
|
className="shrink-0 opacity-0 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenExternal();
|
onOpenExternal();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useAppStore, Feature } from '@/store/app-store';
|
import { useAppStore, Feature } from '@/store/app-store';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { GraphView } from './graph-view';
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
EditFeatureDialog,
|
EditFeatureDialog,
|
||||||
@@ -40,7 +41,20 @@ export function GraphViewPage() {
|
|||||||
addFeatureUseSelectedWorktreeBranch,
|
addFeatureUseSelectedWorktreeBranch,
|
||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
setPlanUseSelectedWorktreeBranch,
|
setPlanUseSelectedWorktreeBranch,
|
||||||
} = useAppStore();
|
} = useAppStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
currentProject: state.currentProject,
|
||||||
|
updateFeature: state.updateFeature,
|
||||||
|
getCurrentWorktree: state.getCurrentWorktree,
|
||||||
|
getWorktrees: state.getWorktrees,
|
||||||
|
setWorktrees: state.setWorktrees,
|
||||||
|
setCurrentWorktree: state.setCurrentWorktree,
|
||||||
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
|
addFeatureUseSelectedWorktreeBranch: state.addFeatureUseSelectedWorktreeBranch,
|
||||||
|
planUseSelectedWorktreeBranch: state.planUseSelectedWorktreeBranch,
|
||||||
|
setPlanUseSelectedWorktreeBranch: state.setPlanUseSelectedWorktreeBranch,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure worktrees are loaded when landing directly on graph view
|
// Ensure worktrees are loaded when landing directly on graph view
|
||||||
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
useWorktrees({ projectPath: currentProject?.path ?? '' });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
|
||||||
|
|
||||||
export interface DependencyEdgeData {
|
export interface DependencyEdgeData {
|
||||||
sourceStatus: Feature['status'];
|
sourceStatus: Feature['status'];
|
||||||
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||||
@@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
|
|
||||||
const isHighlighted = edgeData?.isHighlighted ?? false;
|
const isHighlighted = edgeData?.isHighlighted ?? false;
|
||||||
const isDimmed = edgeData?.isDimmed ?? false;
|
const isDimmed = edgeData?.isDimmed ?? false;
|
||||||
|
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
|
||||||
|
|
||||||
const edgeColor = isHighlighted
|
const edgeColor = isHighlighted
|
||||||
? 'var(--brand-500)'
|
? 'var(--brand-500)'
|
||||||
@@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
className={cn('transition-opacity duration-200', isDimmed && 'graph-edge-dimmed')}
|
||||||
|
style={{
|
||||||
|
strokeWidth: selected ? 2 : 1.5,
|
||||||
|
stroke: selected ? 'var(--status-error)' : edgeColor,
|
||||||
|
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||||
|
opacity: isDimmed ? 0.2 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selected && edgeData?.onDeleteDependency && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center',
|
||||||
|
'w-6 h-6 rounded-full',
|
||||||
|
'bg-[var(--status-error)] hover:bg-[var(--status-error)]/80',
|
||||||
|
'text-white shadow-lg',
|
||||||
|
'transition-all duration-150',
|
||||||
|
'hover:scale-110'
|
||||||
|
)}
|
||||||
|
title="Delete dependency"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Invisible wider path for hover detection */}
|
{/* Invisible wider path for hover detection */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||||
|
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
|
|
||||||
// Background/theme settings with defaults
|
// Background/theme settings with defaults
|
||||||
const cardOpacity = data.cardOpacity ?? 100;
|
const cardOpacity = data.cardOpacity ?? 100;
|
||||||
const glassmorphism = data.cardGlassmorphism ?? true;
|
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
|
||||||
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
const cardBorderEnabled = data.cardBorderEnabled ?? true;
|
||||||
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
|
||||||
|
const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT;
|
||||||
|
const glassmorphism = shouldUseGlassmorphism && !isCompact;
|
||||||
|
|
||||||
// Get the border color based on status and error state
|
// Get the border color based on status and error state
|
||||||
const borderColor = data.error
|
const borderColor = data.error
|
||||||
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
|||||||
// Get computed border style
|
// Get computed border style
|
||||||
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
|
||||||
|
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Handle
|
||||||
|
id="target"
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
isConnectable={true}
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'hover:!bg-brand-500',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-[200px] max-w-[240px] rounded-lg shadow-sm relative',
|
||||||
|
'transition-all duration-200',
|
||||||
|
selected && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background',
|
||||||
|
isMatched && 'graph-node-matched',
|
||||||
|
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||||
|
isDimmed && 'graph-node-dimmed'
|
||||||
|
)}
|
||||||
|
style={borderStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-lg bg-card"
|
||||||
|
style={{ opacity: cardOpacity / 100 }}
|
||||||
|
/>
|
||||||
|
<div className={cn('relative flex items-center gap-2 px-2.5 py-2', config.bgClass)}>
|
||||||
|
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
|
||||||
|
<span className={cn('text-[11px] font-medium', config.colorClass)}>{config.label}</span>
|
||||||
|
{priorityConf && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded',
|
||||||
|
priorityConf.colorClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative px-2.5 py-2">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-foreground line-clamp-2',
|
||||||
|
data.title ? 'font-medium' : 'font-semibold'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.title || data.description}
|
||||||
|
</p>
|
||||||
|
{data.title && data.description && (
|
||||||
|
<p className="text-[11px] text-muted-foreground line-clamp-1 mt-1">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.isRunning && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-in-progress)]" />
|
||||||
|
Running
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isStopped && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-[10px] text-[var(--status-warning)]">
|
||||||
|
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-warning)]" />
|
||||||
|
Paused
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
id="source"
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
isConnectable={true}
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'hover:!bg-brand-500',
|
||||||
|
data.status === 'completed' || data.status === 'verified'
|
||||||
|
? '!bg-[var(--status-success)]'
|
||||||
|
: '',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Target handle (left side - receives dependencies) */}
|
{/* Target handle (left side - receives dependencies) */}
|
||||||
|
|||||||
7
apps/ui/src/components/views/graph-view/constants.ts
Normal file
7
apps/ui/src/components/views/graph-view/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const GRAPH_RENDER_MODE_FULL = 'full';
|
||||||
|
export const GRAPH_RENDER_MODE_COMPACT = 'compact';
|
||||||
|
|
||||||
|
export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT;
|
||||||
|
|
||||||
|
export const GRAPH_LARGE_NODE_COUNT = 150;
|
||||||
|
export const GRAPH_LARGE_EDGE_COUNT = 300;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
|
|||||||
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
|
||||||
|
import {
|
||||||
|
GRAPH_LARGE_EDGE_COUNT,
|
||||||
|
GRAPH_LARGE_NODE_COUNT,
|
||||||
|
GRAPH_RENDER_MODE_COMPACT,
|
||||||
|
GRAPH_RENDER_MODE_FULL,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -198,6 +204,17 @@ function GraphCanvasInner({
|
|||||||
// Calculate filter results
|
// Calculate filter results
|
||||||
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
|
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
|
||||||
|
|
||||||
|
const estimatedEdgeCount = useMemo(() => {
|
||||||
|
return features.reduce((total, feature) => {
|
||||||
|
const deps = feature.dependencies as string[] | undefined;
|
||||||
|
return total + (deps?.length ?? 0);
|
||||||
|
}, 0);
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
const isLargeGraph =
|
||||||
|
features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT;
|
||||||
|
const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL;
|
||||||
|
|
||||||
// Transform features to nodes and edges with filter results
|
// Transform features to nodes and edges with filter results
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||||
features,
|
features,
|
||||||
@@ -205,6 +222,8 @@ function GraphCanvasInner({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks: nodeActionCallbacks,
|
actionCallbacks: nodeActionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode,
|
||||||
|
enableEdgeAnimations: !isLargeGraph,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
@@ -457,6 +476,8 @@ function GraphCanvasInner({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldRenderVisibleOnly = isLargeGraph;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -478,6 +499,7 @@ function GraphCanvasInner({
|
|||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
onlyRenderVisibleElements={shouldRenderVisibleOnly}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="graph-canvas"
|
className="graph-canvas"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function GraphView({
|
|||||||
planUseSelectedWorktreeBranch,
|
planUseSelectedWorktreeBranch,
|
||||||
onPlanUseSelectedWorktreeBranchChange,
|
onPlanUseSelectedWorktreeBranchChange,
|
||||||
}: GraphViewProps) {
|
}: GraphViewProps) {
|
||||||
const { currentProject } = useAppStore();
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
// Use the same background hook as the board view
|
// Use the same background hook as the board view
|
||||||
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });
|
||||||
|
|||||||
@@ -54,18 +54,42 @@ function getAncestors(
|
|||||||
/**
|
/**
|
||||||
* Traverses down to find all descendants (features that depend on this one)
|
* Traverses down to find all descendants (features that depend on this one)
|
||||||
*/
|
*/
|
||||||
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
|
function getDescendants(
|
||||||
|
featureId: string,
|
||||||
|
dependentsMap: Map<string, string[]>,
|
||||||
|
visited: Set<string>
|
||||||
|
): void {
|
||||||
if (visited.has(featureId)) return;
|
if (visited.has(featureId)) return;
|
||||||
visited.add(featureId);
|
visited.add(featureId);
|
||||||
|
|
||||||
|
const dependents = dependentsMap.get(featureId);
|
||||||
|
if (!dependents || dependents.length === 0) return;
|
||||||
|
|
||||||
|
for (const dependentId of dependents) {
|
||||||
|
getDescendants(dependentId, dependentsMap, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDependentsMap(features: Feature[]): Map<string, string[]> {
|
||||||
|
const dependentsMap = new Map<string, string[]>();
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const deps = feature.dependencies as string[] | undefined;
|
const deps = feature.dependencies as string[] | undefined;
|
||||||
if (deps?.includes(featureId)) {
|
if (!deps || deps.length === 0) continue;
|
||||||
getDescendants(feature.id, features, visited);
|
|
||||||
|
for (const depId of deps) {
|
||||||
|
const existing = dependentsMap.get(depId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(feature.id);
|
||||||
|
} else {
|
||||||
|
dependentsMap.set(depId, [feature.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return dependentsMap;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all edges in the highlighted path
|
* Gets all edges in the highlighted path
|
||||||
*/
|
*/
|
||||||
@@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
|
|||||||
* Gets the effective status of a feature (accounting for running state)
|
* Gets the effective status of a feature (accounting for running state)
|
||||||
* Treats completed (archived) as verified
|
* Treats completed (archived) as verified
|
||||||
*/
|
*/
|
||||||
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
|
function getEffectiveStatus(feature: Feature, runningTaskIds: Set<string>): StatusFilterValue {
|
||||||
if (feature.status === 'in_progress') {
|
if (feature.status === 'in_progress') {
|
||||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
|
||||||
}
|
}
|
||||||
// Treat completed (archived) as verified
|
// Treat completed (archived) as verified
|
||||||
if (feature.status === 'completed') {
|
if (feature.status === 'completed') {
|
||||||
@@ -119,6 +143,7 @@ export function useGraphFilter(
|
|||||||
).sort();
|
).sort();
|
||||||
|
|
||||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
const hasSearchQuery = normalizedQuery.length > 0;
|
const hasSearchQuery = normalizedQuery.length > 0;
|
||||||
const hasCategoryFilter = selectedCategories.length > 0;
|
const hasCategoryFilter = selectedCategories.length > 0;
|
||||||
const hasStatusFilter = selectedStatuses.length > 0;
|
const hasStatusFilter = selectedStatuses.length > 0;
|
||||||
@@ -139,6 +164,7 @@ export function useGraphFilter(
|
|||||||
// Find directly matched nodes
|
// Find directly matched nodes
|
||||||
const matchedNodeIds = new Set<string>();
|
const matchedNodeIds = new Set<string>();
|
||||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||||
|
const dependentsMap = buildDependentsMap(features);
|
||||||
|
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
let matchesSearch = true;
|
let matchesSearch = true;
|
||||||
@@ -159,7 +185,7 @@ export function useGraphFilter(
|
|||||||
|
|
||||||
// Check status match
|
// Check status match
|
||||||
if (hasStatusFilter) {
|
if (hasStatusFilter) {
|
||||||
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
|
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
|
||||||
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +216,7 @@ export function useGraphFilter(
|
|||||||
getAncestors(id, featureMap, highlightedNodeIds);
|
getAncestors(id, featureMap, highlightedNodeIds);
|
||||||
|
|
||||||
// Add all descendants (dependents)
|
// Add all descendants (dependents)
|
||||||
getDescendants(id, features, highlightedNodeIds);
|
getDescendants(id, dependentsMap, highlightedNodeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get edges in the highlighted path
|
// Get edges in the highlighted path
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Node, Edge } from '@xyflow/react';
|
import { Node, Edge } from '@xyflow/react';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver';
|
||||||
|
import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants';
|
||||||
import { GraphFilterResult } from './use-graph-filter';
|
import { GraphFilterResult } from './use-graph-filter';
|
||||||
|
|
||||||
export interface TaskNodeData extends Feature {
|
export interface TaskNodeData extends Feature {
|
||||||
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
|
|||||||
onResumeTask?: () => void;
|
onResumeTask?: () => void;
|
||||||
onSpawnTask?: () => void;
|
onSpawnTask?: () => void;
|
||||||
onDeleteTask?: () => void;
|
onDeleteTask?: () => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||||
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
|
|||||||
isHighlighted?: boolean;
|
isHighlighted?: boolean;
|
||||||
isDimmed?: boolean;
|
isDimmed?: boolean;
|
||||||
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
onDeleteDependency?: (sourceId: string, targetId: string) => void;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export interface NodeActionCallbacks {
|
export interface NodeActionCallbacks {
|
||||||
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
|
|||||||
filterResult?: GraphFilterResult;
|
filterResult?: GraphFilterResult;
|
||||||
actionCallbacks?: NodeActionCallbacks;
|
actionCallbacks?: NodeActionCallbacks;
|
||||||
backgroundSettings?: BackgroundSettings;
|
backgroundSettings?: BackgroundSettings;
|
||||||
|
renderMode?: GraphRenderMode;
|
||||||
|
enableEdgeAnimations?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,14 +83,14 @@ export function useGraphNodes({
|
|||||||
filterResult,
|
filterResult,
|
||||||
actionCallbacks,
|
actionCallbacks,
|
||||||
backgroundSettings,
|
backgroundSettings,
|
||||||
|
renderMode = GRAPH_RENDER_MODE_FULL,
|
||||||
|
enableEdgeAnimations = true,
|
||||||
}: UseGraphNodesProps) {
|
}: UseGraphNodesProps) {
|
||||||
const { nodes, edges } = useMemo(() => {
|
const { nodes, edges } = useMemo(() => {
|
||||||
const nodeList: TaskNode[] = [];
|
const nodeList: TaskNode[] = [];
|
||||||
const edgeList: DependencyEdge[] = [];
|
const edgeList: DependencyEdge[] = [];
|
||||||
const featureMap = new Map<string, Feature>();
|
const featureMap = createFeatureMap(features);
|
||||||
|
const runningTaskIds = new Set(runningAutoTasks);
|
||||||
// Create feature map for quick lookups
|
|
||||||
features.forEach((f) => featureMap.set(f.id, f));
|
|
||||||
|
|
||||||
// Extract filter state
|
// Extract filter state
|
||||||
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||||
@@ -95,8 +100,8 @@ export function useGraphNodes({
|
|||||||
|
|
||||||
// Create nodes
|
// Create nodes
|
||||||
features.forEach((feature) => {
|
features.forEach((feature) => {
|
||||||
const isRunning = runningAutoTasks.includes(feature.id);
|
const isRunning = runningTaskIds.has(feature.id);
|
||||||
const blockingDeps = getBlockingDependencies(feature, features);
|
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
|
||||||
|
|
||||||
// Calculate filter highlight states
|
// Calculate filter highlight states
|
||||||
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||||
@@ -121,6 +126,7 @@ export function useGraphNodes({
|
|||||||
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
|
||||||
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
|
||||||
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
|
||||||
|
renderMode,
|
||||||
// Action callbacks (bound to this feature's ID)
|
// Action callbacks (bound to this feature's ID)
|
||||||
onViewLogs: actionCallbacks?.onViewLogs
|
onViewLogs: actionCallbacks?.onViewLogs
|
||||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||||
@@ -166,13 +172,14 @@ export function useGraphNodes({
|
|||||||
source: depId,
|
source: depId,
|
||||||
target: feature.id,
|
target: feature.id,
|
||||||
type: 'dependency',
|
type: 'dependency',
|
||||||
animated: isRunning || runningAutoTasks.includes(depId),
|
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
|
||||||
data: {
|
data: {
|
||||||
sourceStatus: sourceFeature.status,
|
sourceStatus: sourceFeature.status,
|
||||||
targetStatus: feature.status,
|
targetStatus: feature.status,
|
||||||
isHighlighted: edgeIsHighlighted,
|
isHighlighted: edgeIsHighlighted,
|
||||||
isDimmed: edgeIsDimmed,
|
isDimmed: edgeIsDimmed,
|
||||||
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
onDeleteDependency: actionCallbacks?.onDeleteDependency,
|
||||||
|
renderMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
edgeList.push(edge);
|
edgeList.push(edge);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||||||
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
|
||||||
import { useIdeationStore } from '@/store/ideation-store';
|
import { useIdeationStore } from '@/store/ideation-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
import type { IdeaCategory, IdeationPrompt } from '@automaker/types';
|
||||||
@@ -28,6 +28,9 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
const [loadingPromptId, setLoadingPromptId] = useState<string | null>(null);
|
||||||
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// React Query mutation
|
||||||
|
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
|
||||||
const {
|
const {
|
||||||
getPromptsByCategory,
|
getPromptsByCategory,
|
||||||
isLoading: isLoadingPrompts,
|
isLoading: isLoadingPrompts,
|
||||||
@@ -57,7 +60,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
|
if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
|
||||||
|
|
||||||
setLoadingPromptId(prompt.id);
|
setLoadingPromptId(prompt.id);
|
||||||
|
|
||||||
@@ -69,17 +72,12 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
toast.info(`Generating ideas for "${prompt.title}"...`);
|
toast.info(`Generating ideas for "${prompt.title}"...`);
|
||||||
setMode('dashboard');
|
setMode('dashboard');
|
||||||
|
|
||||||
try {
|
generateMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ promptId: prompt.id, category },
|
||||||
const result = await api.ideation?.generateSuggestions(
|
{
|
||||||
currentProject.path,
|
onSuccess: (data) => {
|
||||||
prompt.id,
|
updateJobStatus(jobId, 'ready', data.suggestions);
|
||||||
category
|
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
|
||||||
);
|
|
||||||
|
|
||||||
if (result?.success && result.suggestions) {
|
|
||||||
updateJobStatus(jobId, 'ready', result.suggestions);
|
|
||||||
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
|
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
action: {
|
action: {
|
||||||
label: 'View Ideas',
|
label: 'View Ideas',
|
||||||
@@ -89,22 +87,16 @@ export function PromptList({ category, onBack }: PromptListProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
updateJobStatus(
|
|
||||||
jobId,
|
|
||||||
'error',
|
|
||||||
undefined,
|
|
||||||
result?.error || 'Failed to generate suggestions'
|
|
||||||
);
|
|
||||||
toast.error(result?.error || 'Failed to generate suggestions');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate suggestions:', error);
|
|
||||||
updateJobStatus(jobId, 'error', undefined, (error as Error).message);
|
|
||||||
toast.error((error as Error).message);
|
|
||||||
} finally {
|
|
||||||
setLoadingPromptId(null);
|
setLoadingPromptId(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Failed to generate suggestions:', error);
|
||||||
|
updateJobStatus(jobId, 'error', undefined, error.message);
|
||||||
|
toast.error(error.message);
|
||||||
|
setLoadingPromptId(null);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,123 +1,66 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
/**
|
||||||
|
* Running Agents View
|
||||||
|
*
|
||||||
|
* Displays all currently running agents across all projects.
|
||||||
|
* Uses React Query for data fetching with automatic polling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI, RunningAgent } from '@/lib/electron';
|
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||||
|
import { useRunningAgents } from '@/hooks/queries';
|
||||||
const logger = createLogger('RunningAgentsView');
|
import { useStopFeature } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function RunningAgentsView() {
|
export function RunningAgentsView() {
|
||||||
const [runningAgents, setRunningAgents] = useState<RunningAgent[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||||
const { setCurrentProject, projects } = useAppStore();
|
const { setCurrentProject, projects } = useAppStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fetchRunningAgents = useCallback(async () => {
|
const logger = createLogger('RunningAgentsView');
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api.runningAgents) {
|
|
||||||
logger.debug('Fetching running agents list');
|
|
||||||
const result = await api.runningAgents.getAll();
|
|
||||||
if (result.success && result.runningAgents) {
|
|
||||||
logger.debug('Running agents list fetched', {
|
|
||||||
count: result.runningAgents.length,
|
|
||||||
});
|
|
||||||
setRunningAgents(result.runningAgents);
|
|
||||||
} else {
|
|
||||||
logger.debug('Running agents list fetch returned empty/failed', {
|
|
||||||
success: result.success,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.debug('Running agents API not available');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching running agents:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initial fetch
|
// Use React Query for running agents with auto-refresh
|
||||||
useEffect(() => {
|
const { data, isLoading, isFetching, refetch } = useRunningAgents();
|
||||||
fetchRunningAgents();
|
|
||||||
}, [fetchRunningAgents]);
|
|
||||||
|
|
||||||
// Auto-refresh every 2 seconds
|
const runningAgents = data?.agents ?? [];
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchRunningAgents();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
// Use mutation for stopping features
|
||||||
}, [fetchRunningAgents]);
|
const stopFeature = useStopFeature();
|
||||||
|
|
||||||
// Subscribe to auto-mode events to update in real-time
|
|
||||||
useEffect(() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.autoMode) {
|
|
||||||
logger.debug('Auto mode API not available for running agents view');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
|
||||||
logger.debug('Auto mode event in running agents view', {
|
|
||||||
type: event.type,
|
|
||||||
});
|
|
||||||
// When a feature completes or errors, refresh the list
|
|
||||||
if (event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') {
|
|
||||||
fetchRunningAgents();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [fetchRunningAgents]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
logger.debug('Manual refresh requested for running agents');
|
refetch();
|
||||||
setRefreshing(true);
|
}, [refetch]);
|
||||||
fetchRunningAgents();
|
|
||||||
}, [fetchRunningAgents]);
|
|
||||||
|
|
||||||
const handleStopAgent = useCallback(
|
const handleStopAgent = useCallback(
|
||||||
async (agent: RunningAgent) => {
|
async (agent: RunningAgent) => {
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
// Handle backlog plans separately - they use a different API
|
||||||
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
|
||||||
if (isBacklogPlan && api.backlogPlan) {
|
if (isBacklogPlan && api.backlogPlan) {
|
||||||
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
|
||||||
|
try {
|
||||||
await api.backlogPlan.stop();
|
await api.backlogPlan.stop();
|
||||||
fetchRunningAgents();
|
} catch (error) {
|
||||||
|
logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error });
|
||||||
|
} finally {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (api.autoMode) {
|
// Use mutation for regular features
|
||||||
logger.debug('Stopping running agent', { featureId: agent.featureId });
|
stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
|
||||||
await api.autoMode.stopFeature(agent.featureId);
|
|
||||||
// Refresh list after stopping
|
|
||||||
fetchRunningAgents();
|
|
||||||
} else {
|
|
||||||
logger.debug('Auto mode API not available to stop agent', { featureId: agent.featureId });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error stopping agent:', error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[fetchRunningAgents]
|
[stopFeature, refetch, logger]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNavigateToProject = useCallback(
|
const handleNavigateToProject = useCallback(
|
||||||
(agent: RunningAgent) => {
|
(agent: RunningAgent) => {
|
||||||
// Find the project by path
|
|
||||||
const project = projects.find((p) => p.path === agent.projectPath);
|
const project = projects.find((p) => p.path === agent.projectPath);
|
||||||
if (project) {
|
if (project) {
|
||||||
logger.debug('Navigating to running agent project', {
|
logger.debug('Navigating to running agent project', {
|
||||||
@@ -144,7 +87,7 @@ export function RunningAgentsView() {
|
|||||||
setSelectedAgent(agent);
|
setSelectedAgent(agent);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<Spinner size="xl" />
|
<Spinner size="xl" />
|
||||||
@@ -169,8 +112,8 @@ export function RunningAgentsView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
|
||||||
{refreshing ? (
|
{isFetching ? (
|
||||||
<Spinner size="sm" className="mr-2" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
@@ -258,7 +201,12 @@ export function RunningAgentsView() {
|
|||||||
>
|
>
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onClick={() => handleStopAgent(agent)}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStopAgent(agent)}
|
||||||
|
disabled={stopFeature.isPending}
|
||||||
|
>
|
||||||
<Square className="h-3.5 w-3.5 mr-1.5" />
|
<Square className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useClaudeUsage } from '@/hooks/queries';
|
||||||
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 { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
const ERROR_NO_API = 'Claude usage API not available';
|
|
||||||
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
const CLAUDE_USAGE_TITLE = 'Claude Usage';
|
||||||
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
|
||||||
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
|
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
|
||||||
@@ -15,13 +13,10 @@ const CLAUDE_LOGIN_COMMAND = 'claude login';
|
|||||||
const CLAUDE_NO_USAGE_MESSAGE =
|
const CLAUDE_NO_USAGE_MESSAGE =
|
||||||
'Usage limits are not available yet. Try refreshing if this persists.';
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
const UPDATED_LABEL = 'Updated';
|
const UPDATED_LABEL = 'Updated';
|
||||||
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
|
|
||||||
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
|
||||||
const WARNING_THRESHOLD = 75;
|
const WARNING_THRESHOLD = 75;
|
||||||
const CAUTION_THRESHOLD = 50;
|
const CAUTION_THRESHOLD = 50;
|
||||||
const MAX_PERCENTAGE = 100;
|
const MAX_PERCENTAGE = 100;
|
||||||
const REFRESH_INTERVAL_MS = 60_000;
|
|
||||||
const STALE_THRESHOLD_MS = 2 * 60_000;
|
|
||||||
// Using purple/indigo for Claude branding
|
// Using purple/indigo for Claude branding
|
||||||
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||||
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
@@ -81,77 +76,31 @@ function UsageCard({
|
|||||||
|
|
||||||
export function ClaudeUsageSection() {
|
export function ClaudeUsageSection() {
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const canFetchUsage = !!claudeAuthStatus?.authenticated;
|
const canFetchUsage = !!claudeAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
// Use React Query for data fetching with automatic polling
|
||||||
|
const {
|
||||||
|
data: claudeUsage,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
dataUpdatedAt,
|
||||||
|
refetch,
|
||||||
|
} = useClaudeUsage(canFetchUsage);
|
||||||
|
|
||||||
// If we have usage data, we can show it even if auth status is unsure
|
// If we have usage data, we can show it even if auth status is unsure
|
||||||
const hasUsage = !!claudeUsage;
|
const hasUsage = !!claudeUsage;
|
||||||
|
|
||||||
const lastUpdatedLabel = claudeUsageLastUpdated
|
const lastUpdatedLabel = useMemo(() => {
|
||||||
? new Date(claudeUsageLastUpdated).toLocaleString()
|
return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
|
||||||
: null;
|
}, [dataUpdatedAt]);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
|
||||||
|
|
||||||
const showAuthWarning =
|
const showAuthWarning =
|
||||||
(!canFetchUsage && !hasUsage && !isLoading) ||
|
(!canFetchUsage && !hasUsage && !isLoading) ||
|
||||||
(error && error.includes('Authentication required'));
|
(errorMessage && errorMessage.includes('Authentication required'));
|
||||||
|
|
||||||
const isStale =
|
|
||||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
|
||||||
|
|
||||||
const fetchUsage = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.claude) {
|
|
||||||
setError(ERROR_NO_API);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.claude.getUsage();
|
|
||||||
|
|
||||||
if ('error' in result) {
|
|
||||||
// Check for auth errors specifically
|
|
||||||
if (
|
|
||||||
result.message?.includes('Authentication required') ||
|
|
||||||
result.error?.includes('Authentication required')
|
|
||||||
) {
|
|
||||||
// We'll show the auth warning UI instead of a generic error
|
|
||||||
} else {
|
|
||||||
setError(result.message || result.error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setClaudeUsage(result);
|
|
||||||
} catch (fetchError) {
|
|
||||||
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [setClaudeUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initial fetch if authenticated and stale
|
|
||||||
// Compute staleness inside effect to avoid re-running when Date.now() changes
|
|
||||||
const isDataStale =
|
|
||||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
|
|
||||||
if (canFetchUsage && isDataStale) {
|
|
||||||
void fetchUsage();
|
|
||||||
}
|
|
||||||
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canFetchUsage) return undefined;
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
void fetchUsage();
|
|
||||||
}, REFRESH_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [fetchUsage, canFetchUsage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -173,13 +122,13 @@ export function ClaudeUsageSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={fetchUsage}
|
onClick={() => refetch()}
|
||||||
disabled={isLoading}
|
disabled={isFetching}
|
||||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
data-testid="refresh-claude-usage"
|
data-testid="refresh-claude-usage"
|
||||||
title={CLAUDE_REFRESH_LABEL}
|
title={CLAUDE_REFRESH_LABEL}
|
||||||
>
|
>
|
||||||
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
|
||||||
@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !showAuthWarning && (
|
{errorMessage && !showAuthWarning && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||||
<div className="text-sm text-red-400">{error}</div>
|
<div className="text-sm text-red-400">{errorMessage}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -220,7 +169,7 @@ export function ClaudeUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasUsage && !error && !showAuthWarning && !isLoading && (
|
{!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
|
||||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
{CLAUDE_NO_USAGE_MESSAGE}
|
{CLAUDE_NO_USAGE_MESSAGE}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -35,10 +36,6 @@ function getAuthMethodLabel(method: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClaudeCliStatusSkeleton() {
|
function ClaudeCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -30,10 +31,6 @@ function getAuthMethodLabel(method: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CodexCliStatusSkeleton() {
|
function CodexCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -20,10 +21,6 @@ interface CursorCliStatusProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CursorCliStatusSkeleton() {
|
export function CursorCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { SkeletonPulse } from '@/components/ui/skeleton';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
|
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -75,10 +76,6 @@ interface OpencodeCliStatusProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpencodeCliStatusSkeleton() {
|
export function OpencodeCliStatusSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { useCallback, useEffect, useState } from '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 { RefreshCw, AlertCircle } from 'lucide-react';
|
import { RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import {
|
import {
|
||||||
formatCodexPlanType,
|
formatCodexPlanType,
|
||||||
formatCodexResetTime,
|
formatCodexResetTime,
|
||||||
getCodexWindowLabel,
|
getCodexWindowLabel,
|
||||||
} from '@/lib/codex-usage-format';
|
} from '@/lib/codex-usage-format';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store';
|
import { useCodexUsage } from '@/hooks/queries';
|
||||||
|
import type { CodexRateLimitWindow } from '@/store/app-store';
|
||||||
|
|
||||||
const ERROR_NO_API = 'Codex usage API not available';
|
|
||||||
const CODEX_USAGE_TITLE = 'Codex Usage';
|
const CODEX_USAGE_TITLE = 'Codex Usage';
|
||||||
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
|
||||||
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
|
const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.';
|
||||||
@@ -22,14 +19,11 @@ const CODEX_LOGIN_COMMAND = 'codex login';
|
|||||||
const CODEX_NO_USAGE_MESSAGE =
|
const CODEX_NO_USAGE_MESSAGE =
|
||||||
'Usage limits are not available yet. Try refreshing if this persists.';
|
'Usage limits are not available yet. Try refreshing if this persists.';
|
||||||
const UPDATED_LABEL = 'Updated';
|
const UPDATED_LABEL = 'Updated';
|
||||||
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
|
|
||||||
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
|
||||||
const PLAN_LABEL = 'Plan';
|
const PLAN_LABEL = 'Plan';
|
||||||
const WARNING_THRESHOLD = 75;
|
const WARNING_THRESHOLD = 75;
|
||||||
const CAUTION_THRESHOLD = 50;
|
const CAUTION_THRESHOLD = 50;
|
||||||
const MAX_PERCENTAGE = 100;
|
const MAX_PERCENTAGE = 100;
|
||||||
const REFRESH_INTERVAL_MS = 60_000;
|
|
||||||
const STALE_THRESHOLD_MS = 2 * 60_000;
|
|
||||||
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
const USAGE_COLOR_CRITICAL = 'bg-red-500';
|
||||||
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
const USAGE_COLOR_WARNING = 'bg-amber-500';
|
||||||
const USAGE_COLOR_OK = 'bg-emerald-500';
|
const USAGE_COLOR_OK = 'bg-emerald-500';
|
||||||
@@ -40,11 +34,12 @@ const isRateLimitWindow = (
|
|||||||
|
|
||||||
export function CodexUsageSection() {
|
export function CodexUsageSection() {
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const canFetchUsage = !!codexAuthStatus?.authenticated;
|
const canFetchUsage = !!codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
// Use React Query for data fetching with automatic polling
|
||||||
|
const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
|
||||||
|
|
||||||
const rateLimits = codexUsage?.rateLimits ?? null;
|
const rateLimits = codexUsage?.rateLimits ?? null;
|
||||||
const primary = rateLimits?.primary ?? null;
|
const primary = rateLimits?.primary ?? null;
|
||||||
const secondary = rateLimits?.secondary ?? null;
|
const secondary = rateLimits?.secondary ?? null;
|
||||||
@@ -55,46 +50,7 @@ export function CodexUsageSection() {
|
|||||||
? new Date(codexUsage.lastUpdated).toLocaleString()
|
? new Date(codexUsage.lastUpdated).toLocaleString()
|
||||||
: null;
|
: null;
|
||||||
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
|
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
|
||||||
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
|
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
|
||||||
|
|
||||||
const fetchUsage = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.codex) {
|
|
||||||
setError(ERROR_NO_API);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.codex.getUsage();
|
|
||||||
if ('error' in result) {
|
|
||||||
setError(result.message || result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCodexUsage(result);
|
|
||||||
} catch (fetchError) {
|
|
||||||
const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR;
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [setCodexUsage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (canFetchUsage && isStale) {
|
|
||||||
void fetchUsage();
|
|
||||||
}
|
|
||||||
}, [fetchUsage, canFetchUsage, isStale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canFetchUsage) return undefined;
|
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
void fetchUsage();
|
|
||||||
}, REFRESH_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
}, [fetchUsage, canFetchUsage]);
|
|
||||||
|
|
||||||
const getUsageColor = (percentage: number) => {
|
const getUsageColor = (percentage: number) => {
|
||||||
if (percentage >= WARNING_THRESHOLD) {
|
if (percentage >= WARNING_THRESHOLD) {
|
||||||
@@ -163,13 +119,13 @@ export function CodexUsageSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={fetchUsage}
|
onClick={() => refetch()}
|
||||||
disabled={isLoading}
|
disabled={isFetching}
|
||||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||||
data-testid="refresh-codex-usage"
|
data-testid="refresh-codex-usage"
|
||||||
title={CODEX_REFRESH_LABEL}
|
title={CODEX_REFRESH_LABEL}
|
||||||
>
|
>
|
||||||
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
{isFetching ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
||||||
@@ -183,10 +139,10 @@ export function CodexUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{errorMessage && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||||
<div className="text-sm text-red-400">{error}</div>
|
<div className="text-sm text-red-400">{errorMessage}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasMetrics && (
|
{hasMetrics && (
|
||||||
@@ -211,7 +167,7 @@ export function CodexUsageSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hasMetrics && !error && canFetchUsage && !isLoading && (
|
{!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
|
||||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||||
{CODEX_NO_USAGE_MESSAGE}
|
{CODEX_NO_USAGE_MESSAGE}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +1,52 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
|
||||||
import { toast } from 'sonner';
|
import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
|
||||||
|
|
||||||
const logger = createLogger('CursorPermissions');
|
// Re-export for backward compatibility
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
export type PermissionsData = CursorPermissionsData;
|
||||||
import type { CursorPermissionProfile } from '@automaker/types';
|
|
||||||
|
|
||||||
export interface PermissionsData {
|
|
||||||
activeProfile: CursorPermissionProfile | null;
|
|
||||||
effectivePermissions: { allow: string[]; deny: string[] } | null;
|
|
||||||
hasProjectConfig: boolean;
|
|
||||||
availableProfiles: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
permissions: { allow: string[]; deny: string[] };
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing Cursor CLI permissions
|
* Custom hook for managing Cursor CLI permissions
|
||||||
* Handles loading permissions data, applying profiles, and copying configs
|
* Handles loading permissions data, applying profiles, and copying configs
|
||||||
*/
|
*/
|
||||||
export function useCursorPermissions(projectPath?: string) {
|
export function useCursorPermissions(projectPath?: string) {
|
||||||
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
|
||||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
|
||||||
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
|
|
||||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||||
|
|
||||||
// Load permissions data
|
// React Query hooks
|
||||||
const loadPermissions = useCallback(async () => {
|
const permissionsQuery = useCursorPermissionsQuery(projectPath);
|
||||||
setIsLoadingPermissions(true);
|
const applyProfileMutation = useApplyCursorProfile(projectPath);
|
||||||
try {
|
const copyConfigMutation = useCopyCursorConfig();
|
||||||
const api = getHttpApiClient();
|
|
||||||
const result = await api.setup.getCursorPermissions(projectPath);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setPermissions({
|
|
||||||
activeProfile: result.activeProfile || null,
|
|
||||||
effectivePermissions: result.effectivePermissions || null,
|
|
||||||
hasProjectConfig: result.hasProjectConfig || false,
|
|
||||||
availableProfiles: result.availableProfiles || [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load Cursor permissions:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingPermissions(false);
|
|
||||||
}
|
|
||||||
}, [projectPath]);
|
|
||||||
|
|
||||||
// Apply a permission profile
|
// Apply a permission profile
|
||||||
const applyProfile = useCallback(
|
const applyProfile = useCallback(
|
||||||
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
|
(profileId: 'strict' | 'development', scope: 'global' | 'project') => {
|
||||||
setIsSavingPermissions(true);
|
applyProfileMutation.mutate({ profileId, scope });
|
||||||
try {
|
|
||||||
const api = getHttpApiClient();
|
|
||||||
const result = await api.setup.applyCursorPermissionProfile(
|
|
||||||
profileId,
|
|
||||||
scope,
|
|
||||||
scope === 'project' ? projectPath : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(result.message || `Applied ${profileId} profile`);
|
|
||||||
await loadPermissions();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error || 'Failed to apply profile');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to apply profile');
|
|
||||||
} finally {
|
|
||||||
setIsSavingPermissions(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[projectPath, loadPermissions]
|
[applyProfileMutation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy example config to clipboard
|
// Copy example config to clipboard
|
||||||
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
|
const copyConfig = useCallback(
|
||||||
try {
|
(profileId: 'strict' | 'development') => {
|
||||||
const api = getHttpApiClient();
|
copyConfigMutation.mutate(profileId, {
|
||||||
const result = await api.setup.getCursorExampleConfig(profileId);
|
onSuccess: () => {
|
||||||
|
|
||||||
if (result.success && result.config) {
|
|
||||||
await navigator.clipboard.writeText(result.config);
|
|
||||||
setCopiedConfig(true);
|
setCopiedConfig(true);
|
||||||
toast.success('Config copied to clipboard');
|
|
||||||
setTimeout(() => setCopiedConfig(false), 2000);
|
setTimeout(() => setCopiedConfig(false), 2000);
|
||||||
}
|
},
|
||||||
} catch (error) {
|
});
|
||||||
toast.error('Failed to copy config');
|
},
|
||||||
}
|
[copyConfigMutation]
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
|
// Load permissions (refetch)
|
||||||
|
const loadPermissions = useCallback(() => {
|
||||||
|
permissionsQuery.refetch();
|
||||||
|
}, [permissionsQuery]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
permissions,
|
permissions: permissionsQuery.data ?? null,
|
||||||
isLoadingPermissions,
|
isLoadingPermissions: permissionsQuery.isLoading,
|
||||||
isSavingPermissions,
|
isSavingPermissions: applyProfileMutation.isPending,
|
||||||
copiedConfig,
|
copiedConfig,
|
||||||
loadPermissions,
|
loadPermissions,
|
||||||
applyProfile,
|
applyProfile,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useEffect, useMemo, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { useCursorCliStatus } from '@/hooks/queries';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
const logger = createLogger('CursorStatus');
|
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
export interface CursorStatus {
|
export interface CursorStatus {
|
||||||
@@ -15,52 +11,42 @@ export interface CursorStatus {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing Cursor CLI status
|
* Custom hook for managing Cursor CLI status
|
||||||
* Handles checking CLI installation, authentication, and refresh functionality
|
* Uses React Query for data fetching with automatic caching.
|
||||||
*/
|
*/
|
||||||
export function useCursorStatus() {
|
export function useCursorStatus() {
|
||||||
const { setCursorCliStatus } = useSetupStore();
|
const { setCursorCliStatus } = useSetupStore();
|
||||||
|
const { data: result, isLoading, refetch } = useCursorCliStatus();
|
||||||
|
|
||||||
const [status, setStatus] = useState<CursorStatus | null>(null);
|
// Transform the API result into the local CursorStatus shape
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const status = useMemo((): CursorStatus | null => {
|
||||||
|
if (!result) return null;
|
||||||
const loadData = useCallback(async () => {
|
return {
|
||||||
setIsLoading(true);
|
installed: result.installed ?? false,
|
||||||
try {
|
version: result.version ?? undefined,
|
||||||
const api = getHttpApiClient();
|
authenticated: result.auth?.authenticated ?? false,
|
||||||
const statusResult = await api.setup.getCursorStatus();
|
method: result.auth?.method,
|
||||||
|
|
||||||
if (statusResult.success) {
|
|
||||||
const newStatus = {
|
|
||||||
installed: statusResult.installed ?? false,
|
|
||||||
version: statusResult.version ?? undefined,
|
|
||||||
authenticated: statusResult.auth?.authenticated ?? false,
|
|
||||||
method: statusResult.auth?.method,
|
|
||||||
};
|
};
|
||||||
setStatus(newStatus);
|
}, [result]);
|
||||||
|
|
||||||
// Also update the global setup store so other components can access the status
|
// Keep the global setup store in sync with query data
|
||||||
|
useEffect(() => {
|
||||||
|
if (status) {
|
||||||
setCursorCliStatus({
|
setCursorCliStatus({
|
||||||
installed: newStatus.installed,
|
installed: status.installed,
|
||||||
version: newStatus.version,
|
version: status.version,
|
||||||
auth: newStatus.authenticated
|
auth: status.authenticated
|
||||||
? {
|
? {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: newStatus.method || 'unknown',
|
method: status.method || 'unknown',
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}, [status, setCursorCliStatus]);
|
||||||
logger.error('Failed to load Cursor settings:', error);
|
|
||||||
toast.error('Failed to load Cursor settings');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [setCursorCliStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = useCallback(() => {
|
||||||
loadData();
|
refetch();
|
||||||
}, [loadData]);
|
}, [refetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -5,59 +5,53 @@
|
|||||||
* configuring which sources to load Skills from (user/project).
|
* configuring which sources to load Skills from (user/project).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useUpdateGlobalSettings } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function useSkillsSettings() {
|
export function useSkillsSettings() {
|
||||||
const enabled = useAppStore((state) => state.enableSkills);
|
const enabled = useAppStore((state) => state.enableSkills);
|
||||||
const sources = useAppStore((state) => state.skillsSources);
|
const sources = useAppStore((state) => state.skillsSources);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const updateEnabled = async (newEnabled: boolean) => {
|
// React Query mutation (disable default toast)
|
||||||
setIsLoading(true);
|
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const updateEnabled = useCallback(
|
||||||
if (!api.settings) {
|
(newEnabled: boolean) => {
|
||||||
throw new Error('Settings API not available');
|
updateSettingsMutation.mutate(
|
||||||
}
|
{ enableSkills: newEnabled },
|
||||||
await api.settings.updateGlobal({ enableSkills: newEnabled });
|
{
|
||||||
// Update local store after successful server update
|
onSuccess: () => {
|
||||||
useAppStore.setState({ enableSkills: newEnabled });
|
useAppStore.setState({ enableSkills: newEnabled });
|
||||||
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
|
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update skills settings');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
const updateSources = useCallback(
|
||||||
setIsLoading(true);
|
(newSources: Array<'user' | 'project'>) => {
|
||||||
try {
|
updateSettingsMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ skillsSources: newSources },
|
||||||
if (!api.settings) {
|
{
|
||||||
throw new Error('Settings API not available');
|
onSuccess: () => {
|
||||||
}
|
|
||||||
await api.settings.updateGlobal({ skillsSources: newSources });
|
|
||||||
// Update local store after successful server update
|
|
||||||
useAppStore.setState({ skillsSources: newSources });
|
useAppStore.setState({ skillsSources: newSources });
|
||||||
toast.success('Skills sources updated');
|
toast.success('Skills sources updated');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update skills sources');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
updateEnabled,
|
updateEnabled,
|
||||||
updateSources,
|
updateSources,
|
||||||
isLoading,
|
isLoading: updateSettingsMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,59 +5,53 @@
|
|||||||
* configuring which sources to load Subagents from (user/project).
|
* configuring which sources to load Subagents from (user/project).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useUpdateGlobalSettings } from '@/hooks/mutations';
|
||||||
|
|
||||||
export function useSubagentsSettings() {
|
export function useSubagentsSettings() {
|
||||||
const enabled = useAppStore((state) => state.enableSubagents);
|
const enabled = useAppStore((state) => state.enableSubagents);
|
||||||
const sources = useAppStore((state) => state.subagentsSources);
|
const sources = useAppStore((state) => state.subagentsSources);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const updateEnabled = async (newEnabled: boolean) => {
|
// React Query mutation (disable default toast)
|
||||||
setIsLoading(true);
|
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
const updateEnabled = useCallback(
|
||||||
if (!api.settings) {
|
(newEnabled: boolean) => {
|
||||||
throw new Error('Settings API not available');
|
updateSettingsMutation.mutate(
|
||||||
}
|
{ enableSubagents: newEnabled },
|
||||||
await api.settings.updateGlobal({ enableSubagents: newEnabled });
|
{
|
||||||
// Update local store after successful server update
|
onSuccess: () => {
|
||||||
useAppStore.setState({ enableSubagents: newEnabled });
|
useAppStore.setState({ enableSubagents: newEnabled });
|
||||||
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
|
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update subagents settings');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
const updateSources = async (newSources: Array<'user' | 'project'>) => {
|
const updateSources = useCallback(
|
||||||
setIsLoading(true);
|
(newSources: Array<'user' | 'project'>) => {
|
||||||
try {
|
updateSettingsMutation.mutate(
|
||||||
const api = getElectronAPI();
|
{ subagentsSources: newSources },
|
||||||
if (!api.settings) {
|
{
|
||||||
throw new Error('Settings API not available');
|
onSuccess: () => {
|
||||||
}
|
|
||||||
await api.settings.updateGlobal({ subagentsSources: newSources });
|
|
||||||
// Update local store after successful server update
|
|
||||||
useAppStore.setState({ subagentsSources: newSources });
|
useAppStore.setState({ subagentsSources: newSources });
|
||||||
toast.success('Subagents sources updated');
|
toast.success('Subagents sources updated');
|
||||||
} catch (error) {
|
},
|
||||||
toast.error('Failed to update subagents sources');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
},
|
||||||
|
[updateSettingsMutation]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
updateEnabled,
|
updateEnabled,
|
||||||
updateSources,
|
updateSources,
|
||||||
isLoading,
|
isLoading: updateSettingsMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
* Agent definitions in settings JSON are used server-side only.
|
* Agent definitions in settings JSON are used server-side only.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { AgentDefinition } from '@automaker/types';
|
import type { AgentDefinition } from '@automaker/types';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useDiscoveredAgents } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
export type SubagentScope = 'global' | 'project';
|
export type SubagentScope = 'global' | 'project';
|
||||||
export type SubagentType = 'filesystem';
|
export type SubagentType = 'filesystem';
|
||||||
@@ -35,51 +37,40 @@ interface FilesystemAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSubagents() {
|
export function useSubagents() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
|
|
||||||
|
|
||||||
// Fetch filesystem agents
|
// Use React Query hook for fetching agents
|
||||||
const fetchFilesystemAgents = useCallback(async () => {
|
const {
|
||||||
setIsLoading(true);
|
data: agents = [],
|
||||||
try {
|
isLoading,
|
||||||
const api = getElectronAPI();
|
refetch,
|
||||||
if (!api.settings) {
|
} = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
|
||||||
console.warn('Settings API not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
|
|
||||||
|
|
||||||
if (data.success && data.agents) {
|
// Transform agents to SubagentWithScope format
|
||||||
// Transform filesystem agents to SubagentWithScope format
|
const subagentsWithScope = useMemo((): SubagentWithScope[] => {
|
||||||
const agents: SubagentWithScope[] = data.agents.map(
|
return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
|
||||||
({ name, definition, source, filePath }: FilesystemAgent) => ({
|
|
||||||
name,
|
name,
|
||||||
definition,
|
definition,
|
||||||
scope: source === 'user' ? 'global' : 'project',
|
scope: source === 'user' ? 'global' : 'project',
|
||||||
type: 'filesystem' as const,
|
type: 'filesystem' as const,
|
||||||
source,
|
source,
|
||||||
filePath,
|
filePath,
|
||||||
})
|
}));
|
||||||
);
|
}, [agents]);
|
||||||
setSubagentsWithScope(agents);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch filesystem agents:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
// Fetch filesystem agents on mount and when project changes
|
// Refresh function that invalidates the query cache
|
||||||
useEffect(() => {
|
const refreshFilesystemAgents = useCallback(async () => {
|
||||||
fetchFilesystemAgents();
|
await queryClient.invalidateQueries({
|
||||||
}, [fetchFilesystemAgents]);
|
queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
|
||||||
|
});
|
||||||
|
await refetch();
|
||||||
|
}, [queryClient, currentProject?.path, refetch]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subagentsWithScope,
|
subagentsWithScope,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasProject: !!currentProject,
|
hasProject: !!currentProject,
|
||||||
refreshFilesystemAgents: fetchFilesystemAgents,
|
refreshFilesystemAgents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,239 +1,79 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
|
||||||
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
import { ProviderToggle } from './provider-toggle';
|
import { ProviderToggle } from './provider-toggle';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||||
import type { OpencodeModelId } from '@automaker/types';
|
import type { OpencodeModelId } from '@automaker/types';
|
||||||
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
|
||||||
|
|
||||||
const logger = createLogger('OpencodeSettings');
|
|
||||||
const OPENCODE_PROVIDER_ID = 'opencode';
|
|
||||||
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
|
|
||||||
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
|
|
||||||
|
|
||||||
export function OpencodeSettingsTab() {
|
export function OpencodeSettingsTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
enabledOpencodeModels,
|
enabledOpencodeModels,
|
||||||
opencodeDefaultModel,
|
opencodeDefaultModel,
|
||||||
setOpencodeDefaultModel,
|
setOpencodeDefaultModel,
|
||||||
toggleOpencodeModel,
|
toggleOpencodeModel,
|
||||||
setDynamicOpencodeModels,
|
|
||||||
dynamicOpencodeModels,
|
|
||||||
enabledDynamicModelIds,
|
enabledDynamicModelIds,
|
||||||
toggleDynamicModel,
|
toggleDynamicModel,
|
||||||
cachedOpencodeProviders,
|
|
||||||
setCachedOpencodeProviders,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
|
||||||
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
|
|
||||||
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
|
||||||
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const providerRefreshSignatureRef = useRef<string>('');
|
|
||||||
|
|
||||||
// Phase 1: Load CLI status quickly on mount
|
// React Query hooks for data fetching
|
||||||
useEffect(() => {
|
const {
|
||||||
const checkOpencodeStatus = async () => {
|
data: cliStatusData,
|
||||||
setIsCheckingOpencodeCli(true);
|
isLoading: isCheckingOpencodeCli,
|
||||||
try {
|
refetch: refetchCliStatus,
|
||||||
const api = getElectronAPI();
|
} = useOpencodeCliStatus();
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
|
||||||
const result = await api.setup.getOpencodeStatus();
|
const isCliInstalled = cliStatusData?.installed ?? false;
|
||||||
setCliStatus({
|
|
||||||
success: result.success,
|
const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
|
||||||
status: result.installed ? 'installed' : 'not_installed',
|
|
||||||
method: result.auth?.method,
|
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
|
||||||
version: result.version,
|
|
||||||
path: result.path,
|
// Transform CLI status to the expected format
|
||||||
recommendation: result.recommendation,
|
const cliStatus = useMemo((): SharedCliStatus | null => {
|
||||||
installCommands: result.installCommands,
|
if (!cliStatusData) return null;
|
||||||
});
|
return {
|
||||||
if (result.auth) {
|
success: cliStatusData.success ?? false,
|
||||||
setAuthStatus({
|
status: cliStatusData.installed ? 'installed' : 'not_installed',
|
||||||
authenticated: result.auth.authenticated,
|
method: cliStatusData.auth?.method,
|
||||||
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
|
version: cliStatusData.version,
|
||||||
hasApiKey: result.auth.hasApiKey,
|
path: cliStatusData.path,
|
||||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
recommendation: cliStatusData.recommendation,
|
||||||
hasOAuthToken: result.auth.hasOAuthToken,
|
installCommands: cliStatusData.installCommands,
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCliStatus({
|
|
||||||
success: false,
|
|
||||||
status: 'not_installed',
|
|
||||||
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to check OpenCode CLI status:', error);
|
|
||||||
setCliStatus({
|
|
||||||
success: false,
|
|
||||||
status: 'not_installed',
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCheckingOpencodeCli(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
checkOpencodeStatus();
|
}, [cliStatusData]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Phase 2: Load dynamic models and providers in background (only if not cached)
|
// Transform auth status to the expected format
|
||||||
useEffect(() => {
|
const authStatus = useMemo((): OpencodeAuthStatus | null => {
|
||||||
const loadDynamicContent = async () => {
|
if (!cliStatusData?.auth) return null;
|
||||||
const api = getElectronAPI();
|
return {
|
||||||
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
authenticated: cliStatusData.auth.authenticated,
|
||||||
|
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
|
||||||
if (!isInstalled || !api?.setup) return;
|
hasApiKey: cliStatusData.auth.hasApiKey,
|
||||||
|
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
|
||||||
// Skip if already have cached data
|
hasOAuthToken: cliStatusData.auth.hasOAuthToken,
|
||||||
const needsProviders = cachedOpencodeProviders.length === 0;
|
error: cliStatusData.auth.error,
|
||||||
const needsModels = dynamicOpencodeModels.length === 0;
|
|
||||||
|
|
||||||
if (!needsProviders && !needsModels) return;
|
|
||||||
|
|
||||||
setIsLoadingDynamicModels(true);
|
|
||||||
try {
|
|
||||||
// Load providers if needed
|
|
||||||
if (needsProviders && api.setup.getOpencodeProviders) {
|
|
||||||
const providersResult = await api.setup.getOpencodeProviders();
|
|
||||||
if (providersResult.success && providersResult.providers) {
|
|
||||||
setCachedOpencodeProviders(providersResult.providers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load models if needed
|
|
||||||
if (needsModels && api.setup.getOpencodeModels) {
|
|
||||||
const modelsResult = await api.setup.getOpencodeModels();
|
|
||||||
if (modelsResult.success && modelsResult.models) {
|
|
||||||
setDynamicOpencodeModels(modelsResult.models);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load dynamic content:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDynamicModels(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
loadDynamicContent();
|
}, [cliStatusData]);
|
||||||
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const refreshModelsForNewProviders = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
|
||||||
|
|
||||||
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
|
|
||||||
if (isLoadingDynamicModels) return;
|
|
||||||
|
|
||||||
const authenticatedProviders = cachedOpencodeProviders
|
|
||||||
.filter((provider) => provider.authenticated)
|
|
||||||
.map((provider) => provider.id)
|
|
||||||
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
|
|
||||||
|
|
||||||
if (authenticatedProviders.length === 0) {
|
|
||||||
providerRefreshSignatureRef.current = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dynamicProviderIds = new Set(
|
|
||||||
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
|
|
||||||
);
|
|
||||||
const missingProviders = authenticatedProviders.filter(
|
|
||||||
(providerId) => !dynamicProviderIds.has(providerId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingProviders.length === 0) {
|
|
||||||
providerRefreshSignatureRef.current = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
|
|
||||||
if (providerRefreshSignatureRef.current === signature) return;
|
|
||||||
providerRefreshSignatureRef.current = signature;
|
|
||||||
|
|
||||||
setIsLoadingDynamicModels(true);
|
|
||||||
try {
|
|
||||||
const modelsResult = await api.setup.refreshOpencodeModels();
|
|
||||||
if (modelsResult.success && modelsResult.models) {
|
|
||||||
setDynamicOpencodeModels(modelsResult.models);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to refresh OpenCode models for new providers:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDynamicModels(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
refreshModelsForNewProviders();
|
|
||||||
}, [
|
|
||||||
cachedOpencodeProviders,
|
|
||||||
dynamicOpencodeModels,
|
|
||||||
cliStatus?.success,
|
|
||||||
cliStatus?.status,
|
|
||||||
isLoadingDynamicModels,
|
|
||||||
setDynamicOpencodeModels,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
// Refresh all opencode-related queries
|
||||||
const handleRefreshOpencodeCli = useCallback(async () => {
|
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||||
setIsCheckingOpencodeCli(true);
|
await Promise.all([
|
||||||
setIsLoadingDynamicModels(true);
|
queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
|
||||||
try {
|
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
|
||||||
const api = getElectronAPI();
|
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
|
||||||
if (api?.setup?.getOpencodeStatus) {
|
]);
|
||||||
const result = await api.setup.getOpencodeStatus();
|
await refetchCliStatus();
|
||||||
setCliStatus({
|
|
||||||
success: result.success,
|
|
||||||
status: result.installed ? 'installed' : 'not_installed',
|
|
||||||
method: result.auth?.method,
|
|
||||||
version: result.version,
|
|
||||||
path: result.path,
|
|
||||||
recommendation: result.recommendation,
|
|
||||||
installCommands: result.installCommands,
|
|
||||||
});
|
|
||||||
if (result.auth) {
|
|
||||||
setAuthStatus({
|
|
||||||
authenticated: result.auth.authenticated,
|
|
||||||
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
|
|
||||||
hasApiKey: result.auth.hasApiKey,
|
|
||||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
|
||||||
hasOAuthToken: result.auth.hasOAuthToken,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.installed) {
|
|
||||||
// Refresh providers
|
|
||||||
if (api?.setup?.getOpencodeProviders) {
|
|
||||||
const providersResult = await api.setup.getOpencodeProviders();
|
|
||||||
if (providersResult.success && providersResult.providers) {
|
|
||||||
setCachedOpencodeProviders(providersResult.providers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh dynamic models
|
|
||||||
if (api?.setup?.refreshOpencodeModels) {
|
|
||||||
const modelsResult = await api.setup.refreshOpencodeModels();
|
|
||||||
if (modelsResult.success && modelsResult.models) {
|
|
||||||
setDynamicOpencodeModels(modelsResult.models);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('OpenCode CLI refreshed');
|
toast.success('OpenCode CLI refreshed');
|
||||||
}
|
}, [queryClient, refetchCliStatus]);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to refresh OpenCode CLI status:', error);
|
|
||||||
toast.error('Failed to refresh OpenCode CLI status');
|
|
||||||
} finally {
|
|
||||||
setIsCheckingOpencodeCli(false);
|
|
||||||
setIsLoadingDynamicModels(false);
|
|
||||||
}
|
|
||||||
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
|
|
||||||
|
|
||||||
const handleDefaultModelChange = useCallback(
|
const handleDefaultModelChange = useCallback(
|
||||||
(model: OpencodeModelId) => {
|
(model: OpencodeModelId) => {
|
||||||
@@ -241,7 +81,7 @@ export function OpencodeSettingsTab() {
|
|||||||
try {
|
try {
|
||||||
setOpencodeDefaultModel(model);
|
setOpencodeDefaultModel(model);
|
||||||
toast.success('Default model updated');
|
toast.success('Default model updated');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update default model');
|
toast.error('Failed to update default model');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -255,7 +95,7 @@ export function OpencodeSettingsTab() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
toggleOpencodeModel(model, enabled);
|
toggleOpencodeModel(model, enabled);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update models');
|
toast.error('Failed to update models');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -269,7 +109,7 @@ export function OpencodeSettingsTab() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
toggleDynamicModel(modelId, enabled);
|
toggleDynamicModel(modelId, enabled);
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to update dynamic model');
|
toast.error('Failed to update dynamic model');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -287,7 +127,7 @@ export function OpencodeSettingsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() {
|
|||||||
<OpencodeCliStatus
|
<OpencodeCliStatus
|
||||||
status={cliStatus}
|
status={cliStatus}
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
providers={providersData as OpenCodeProviderInfo[]}
|
||||||
isChecking={isCheckingOpencodeCli}
|
isChecking={isCheckingOpencodeCli}
|
||||||
onRefresh={handleRefreshOpencodeCli}
|
onRefresh={handleRefreshOpencodeCli}
|
||||||
/>
|
/>
|
||||||
@@ -310,8 +150,8 @@ export function OpencodeSettingsTab() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onDefaultModelChange={handleDefaultModelChange}
|
onDefaultModelChange={handleDefaultModelChange}
|
||||||
onModelToggle={handleModelToggle}
|
onModelToggle={handleModelToggle}
|
||||||
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
|
providers={providersData as OpenCodeProviderInfo[]}
|
||||||
dynamicModels={dynamicOpencodeModels}
|
dynamicModels={modelsData}
|
||||||
enabledDynamicModelIds={enabledDynamicModelIds}
|
enabledDynamicModelIds={enabledDynamicModelIds}
|
||||||
onDynamicModelToggle={handleDynamicModelToggle}
|
onDynamicModelToggle={handleDynamicModelToggle}
|
||||||
isLoadingDynamicModels={isLoadingDynamicModels}
|
isLoadingDynamicModels={isLoadingDynamicModels}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createElement } from 'react';
|
|||||||
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
|
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
|
||||||
import type { FeatureCount } from '../types';
|
import type { FeatureCount } from '../types';
|
||||||
import type { SpecRegenerationEvent } from '@/types/electron';
|
import type { SpecRegenerationEvent } from '@/types/electron';
|
||||||
|
import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations';
|
||||||
|
|
||||||
interface UseSpecGenerationOptions {
|
interface UseSpecGenerationOptions {
|
||||||
loadSpec: () => Promise<void>;
|
loadSpec: () => Promise<void>;
|
||||||
@@ -18,6 +19,11 @@ interface UseSpecGenerationOptions {
|
|||||||
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
|
// React Query mutations
|
||||||
|
const createSpecMutation = useCreateSpec(currentProject?.path ?? '');
|
||||||
|
const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? '');
|
||||||
|
const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? '');
|
||||||
|
|
||||||
// Dialog visibility state
|
// Dialog visibility state
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||||
@@ -427,33 +433,17 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
logsRef.current = '';
|
logsRef.current = '';
|
||||||
setLogs('');
|
setLogs('');
|
||||||
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
|
logger.debug('[useSpecGeneration] Starting spec creation, generateFeatures:', generateFeatures);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsCreating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.create(
|
|
||||||
currentProject.path,
|
|
||||||
projectOverview.trim(),
|
|
||||||
generateFeatures,
|
|
||||||
analyzeProjectOnCreate,
|
|
||||||
generateFeatures ? featureCountOnCreate : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
createSpecMutation.mutate(
|
||||||
const errorMsg = result.error || 'Unknown error';
|
{
|
||||||
logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
|
projectOverview: projectOverview.trim(),
|
||||||
setIsCreating(false);
|
generateFeatures,
|
||||||
setCurrentPhase('error');
|
analyzeProject: analyzeProjectOnCreate,
|
||||||
setErrorMessage(errorMsg);
|
featureCount: generateFeatures ? featureCountOnCreate : undefined,
|
||||||
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
|
},
|
||||||
logsRef.current = errorLog;
|
{
|
||||||
setLogs(errorLog);
|
onError: (error) => {
|
||||||
}
|
const errorMsg = error.message;
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -461,13 +451,16 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
currentProject,
|
currentProject,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
analyzeProjectOnCreate,
|
analyzeProjectOnCreate,
|
||||||
featureCountOnCreate,
|
featureCountOnCreate,
|
||||||
|
createSpecMutation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleRegenerate = useCallback(async () => {
|
const handleRegenerate = useCallback(async () => {
|
||||||
@@ -483,33 +476,17 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
|
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
|
||||||
generateFeaturesOnRegenerate
|
generateFeaturesOnRegenerate
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsRegenerating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generate(
|
|
||||||
currentProject.path,
|
|
||||||
projectDefinition.trim(),
|
|
||||||
generateFeaturesOnRegenerate,
|
|
||||||
analyzeProjectOnRegenerate,
|
|
||||||
generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
regenerateSpecMutation.mutate(
|
||||||
const errorMsg = result.error || 'Unknown error';
|
{
|
||||||
logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
|
projectDefinition: projectDefinition.trim(),
|
||||||
setIsRegenerating(false);
|
generateFeatures: generateFeaturesOnRegenerate,
|
||||||
setCurrentPhase('error');
|
analyzeProject: analyzeProjectOnRegenerate,
|
||||||
setErrorMessage(errorMsg);
|
featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
|
||||||
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
|
},
|
||||||
logsRef.current = errorLog;
|
{
|
||||||
setLogs(errorLog);
|
onError: (error) => {
|
||||||
}
|
const errorMsg = error.message;
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
|
||||||
setIsRegenerating(false);
|
setIsRegenerating(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -517,13 +494,16 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
currentProject,
|
currentProject,
|
||||||
projectDefinition,
|
projectDefinition,
|
||||||
generateFeaturesOnRegenerate,
|
generateFeaturesOnRegenerate,
|
||||||
analyzeProjectOnRegenerate,
|
analyzeProjectOnRegenerate,
|
||||||
featureCountOnRegenerate,
|
featureCountOnRegenerate,
|
||||||
|
regenerateSpecMutation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleGenerateFeatures = useCallback(async () => {
|
const handleGenerateFeatures = useCallback(async () => {
|
||||||
@@ -536,27 +516,10 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
logsRef.current = '';
|
logsRef.current = '';
|
||||||
setLogs('');
|
setLogs('');
|
||||||
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
|
logger.debug('[useSpecGeneration] Starting feature generation from existing spec');
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
logger.error('[useSpecGeneration] Spec regeneration not available');
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generateFeatures(currentProject.path);
|
|
||||||
|
|
||||||
if (!result.success) {
|
generateFeaturesMutation.mutate(undefined, {
|
||||||
const errorMsg = result.error || 'Unknown error';
|
onError: (error) => {
|
||||||
logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
|
const errorMsg = error.message;
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase('error');
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
|
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
|
||||||
setIsGeneratingFeatures(false);
|
setIsGeneratingFeatures(false);
|
||||||
setCurrentPhase('error');
|
setCurrentPhase('error');
|
||||||
@@ -564,8 +527,9 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
|
|||||||
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
||||||
logsRef.current = errorLog;
|
logsRef.current = errorLog;
|
||||||
setLogs(errorLog);
|
setLogs(errorLog);
|
||||||
}
|
},
|
||||||
}, [currentProject]);
|
});
|
||||||
|
}, [currentProject, generateFeaturesMutation]);
|
||||||
|
|
||||||
const handleSync = useCallback(async () => {
|
const handleSync = useCallback(async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|||||||
@@ -1,62 +1,51 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
|
||||||
const logger = createLogger('SpecLoading');
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
export function useSpecLoading() {
|
export function useSpecLoading() {
|
||||||
const { currentProject, setAppSpec } = useAppStore();
|
const { currentProject, setAppSpec } = useAppStore();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const queryClient = useQueryClient();
|
||||||
const [specExists, setSpecExists] = useState(true);
|
const [specExists, setSpecExists] = useState(true);
|
||||||
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
|
|
||||||
|
|
||||||
const loadSpec = useCallback(async () => {
|
// React Query hooks
|
||||||
if (!currentProject) return;
|
const specFileQuery = useSpecFile(currentProject?.path);
|
||||||
|
const statusQuery = useSpecRegenerationStatus(currentProject?.path);
|
||||||
|
|
||||||
setIsLoading(true);
|
const isGenerationRunning = statusQuery.data?.isRunning ?? false;
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
|
|
||||||
// Check if spec generation is running
|
|
||||||
if (api.specRegeneration) {
|
|
||||||
const status = await api.specRegeneration.status(currentProject.path);
|
|
||||||
if (status.success && status.isRunning) {
|
|
||||||
logger.debug('Spec generation is running for this project');
|
|
||||||
setIsGenerationRunning(true);
|
|
||||||
} else {
|
|
||||||
setIsGenerationRunning(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsGenerationRunning(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always try to load the spec file, even if generation is running
|
|
||||||
// This allows users to view their existing spec while generating features
|
|
||||||
const result = await api.readFile(`${currentProject.path}/.automaker/app_spec.txt`);
|
|
||||||
|
|
||||||
if (result.success && result.content) {
|
|
||||||
setAppSpec(result.content);
|
|
||||||
setSpecExists(true);
|
|
||||||
} else {
|
|
||||||
// File doesn't exist
|
|
||||||
setAppSpec('');
|
|
||||||
setSpecExists(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to load spec:', error);
|
|
||||||
setSpecExists(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentProject, setAppSpec]);
|
|
||||||
|
|
||||||
|
// Update app store and specExists when spec file data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSpec();
|
if (specFileQuery.data && !isGenerationRunning) {
|
||||||
}, [loadSpec]);
|
setAppSpec(specFileQuery.data.content);
|
||||||
|
setSpecExists(specFileQuery.data.exists);
|
||||||
|
}
|
||||||
|
}, [specFileQuery.data, setAppSpec, isGenerationRunning]);
|
||||||
|
|
||||||
|
// Manual reload function (invalidates cache)
|
||||||
|
const loadSpec = useCallback(async () => {
|
||||||
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
|
// Fetch fresh status data to avoid stale cache issues
|
||||||
|
// Using fetchQuery ensures we get the latest data before checking
|
||||||
|
const statusData = await queryClient.fetchQuery<{ isRunning: boolean }>({
|
||||||
|
queryKey: queryKeys.specRegeneration.status(currentProject.path),
|
||||||
|
staleTime: 0, // Force fresh fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusData?.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate and refetch spec file
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.spec.file(currentProject.path),
|
||||||
|
});
|
||||||
|
}, [currentProject?.path, queryClient]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading: specFileQuery.isLoading,
|
||||||
specExists,
|
specExists,
|
||||||
setSpecExists,
|
setSpecExists,
|
||||||
isGenerationRunning,
|
isGenerationRunning,
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useSaveSpec } from '@/hooks/mutations';
|
||||||
const logger = createLogger('SpecSave');
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
|
|
||||||
export function useSpecSave() {
|
export function useSpecSave() {
|
||||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// React Query mutation
|
||||||
|
const saveMutation = useSaveSpec(currentProject?.path ?? '');
|
||||||
|
|
||||||
const saveSpec = async () => {
|
const saveSpec = async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
|
|
||||||
setIsSaving(true);
|
saveMutation.mutate(appSpec, {
|
||||||
try {
|
onSuccess: () => setHasChanges(false),
|
||||||
const api = getElectronAPI();
|
});
|
||||||
await api.writeFile(`${currentProject.path}/.automaker/app_spec.txt`, appSpec);
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save spec:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
const handleChange = (value: string) => {
|
||||||
@@ -31,7 +23,7 @@ export function useSpecSave() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSaving,
|
isSaving: saveMutation.isPending,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
setHasChanges,
|
setHasChanges,
|
||||||
saveSpec,
|
saveSpec,
|
||||||
|
|||||||
79
apps/ui/src/hooks/mutations/index.ts
Normal file
79
apps/ui/src/hooks/mutations/index.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Mutations Barrel Export
|
||||||
|
*
|
||||||
|
* Central export point for all React Query mutations.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { useCreateFeature, useStartFeature, useCommitWorktree } from '@/hooks/mutations';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Feature mutations
|
||||||
|
export {
|
||||||
|
useCreateFeature,
|
||||||
|
useUpdateFeature,
|
||||||
|
useDeleteFeature,
|
||||||
|
useGenerateTitle,
|
||||||
|
useBatchUpdateFeatures,
|
||||||
|
} from './use-feature-mutations';
|
||||||
|
|
||||||
|
// Auto mode mutations
|
||||||
|
export {
|
||||||
|
useStartFeature,
|
||||||
|
useResumeFeature,
|
||||||
|
useStopFeature,
|
||||||
|
useVerifyFeature,
|
||||||
|
useApprovePlan,
|
||||||
|
useFollowUpFeature,
|
||||||
|
useCommitFeature,
|
||||||
|
useAnalyzeProject,
|
||||||
|
useStartAutoMode,
|
||||||
|
useStopAutoMode,
|
||||||
|
} from './use-auto-mode-mutations';
|
||||||
|
|
||||||
|
// Settings mutations
|
||||||
|
export {
|
||||||
|
useUpdateGlobalSettings,
|
||||||
|
useUpdateProjectSettings,
|
||||||
|
useSaveCredentials,
|
||||||
|
} from './use-settings-mutations';
|
||||||
|
|
||||||
|
// Worktree mutations
|
||||||
|
export {
|
||||||
|
useCreateWorktree,
|
||||||
|
useDeleteWorktree,
|
||||||
|
useCommitWorktree,
|
||||||
|
usePushWorktree,
|
||||||
|
usePullWorktree,
|
||||||
|
useCreatePullRequest,
|
||||||
|
useMergeWorktree,
|
||||||
|
useSwitchBranch,
|
||||||
|
useCheckoutBranch,
|
||||||
|
useGenerateCommitMessage,
|
||||||
|
useOpenInEditor,
|
||||||
|
useInitGit,
|
||||||
|
useSetInitScript,
|
||||||
|
useDeleteInitScript,
|
||||||
|
} from './use-worktree-mutations';
|
||||||
|
|
||||||
|
// GitHub mutations
|
||||||
|
export {
|
||||||
|
useValidateIssue,
|
||||||
|
useMarkValidationViewed,
|
||||||
|
useGetValidationStatus,
|
||||||
|
} from './use-github-mutations';
|
||||||
|
|
||||||
|
// Ideation mutations
|
||||||
|
export { useGenerateIdeationSuggestions } from './use-ideation-mutations';
|
||||||
|
|
||||||
|
// Spec mutations
|
||||||
|
export {
|
||||||
|
useCreateSpec,
|
||||||
|
useRegenerateSpec,
|
||||||
|
useGenerateFeatures,
|
||||||
|
useSaveSpec,
|
||||||
|
} from './use-spec-mutations';
|
||||||
|
|
||||||
|
// Cursor Permissions mutations
|
||||||
|
export { useApplyCursorProfile, useCopyCursorConfig } from './use-cursor-permissions-mutations';
|
||||||
388
apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
Normal file
388
apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Auto Mode Mutations
|
||||||
|
*
|
||||||
|
* React Query mutations for auto mode operations like running features,
|
||||||
|
* stopping features, and plan approval.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start running a feature in auto mode
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for starting a feature
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const startFeature = useStartFeature(projectPath);
|
||||||
|
* startFeature.mutate({ featureId: 'abc123', useWorktrees: true });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useStartFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
featureId,
|
||||||
|
useWorktrees,
|
||||||
|
worktreePath,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
useWorktrees?: boolean;
|
||||||
|
worktreePath?: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.runFeature(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
useWorktrees,
|
||||||
|
worktreePath
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start feature');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to start feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume a paused or interrupted feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for resuming a feature
|
||||||
|
*/
|
||||||
|
export function useResumeFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
featureId,
|
||||||
|
useWorktrees,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
useWorktrees?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.resumeFeature(projectPath, featureId, useWorktrees);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to resume feature');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to resume feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running feature
|
||||||
|
*
|
||||||
|
* @returns Mutation for stopping a feature
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const stopFeature = useStopFeature();
|
||||||
|
* // Simple stop
|
||||||
|
* stopFeature.mutate('feature-id');
|
||||||
|
* // Stop with project path for cache invalidation
|
||||||
|
* stopFeature.mutate({ featureId: 'feature-id', projectPath: '/path/to/project' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useStopFeature() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: string | { featureId: string; projectPath?: string }) => {
|
||||||
|
const featureId = typeof input === 'string' ? input : input.featureId;
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.stopFeature(featureId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to stop feature');
|
||||||
|
}
|
||||||
|
// Return projectPath for use in onSuccess
|
||||||
|
return { ...result, projectPath: typeof input === 'string' ? undefined : input.projectPath };
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
// Also invalidate features cache if projectPath is provided
|
||||||
|
if (data.projectPath) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(data.projectPath) });
|
||||||
|
}
|
||||||
|
toast.success('Feature stopped');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to stop feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a completed feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for verifying a feature
|
||||||
|
*/
|
||||||
|
export function useVerifyFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (featureId: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.verifyFeature(projectPath, featureId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to verify feature');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to verify feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve or reject a plan
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for plan approval
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const approvePlan = useApprovePlan(projectPath);
|
||||||
|
* approvePlan.mutate({ featureId: 'abc', approved: true });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useApprovePlan(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
featureId,
|
||||||
|
approved,
|
||||||
|
editedPlan,
|
||||||
|
feedback,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
approved: boolean;
|
||||||
|
editedPlan?: string;
|
||||||
|
feedback?: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.approvePlan(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
approved,
|
||||||
|
editedPlan,
|
||||||
|
feedback
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to submit plan decision');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { approved }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
if (approved) {
|
||||||
|
toast.success('Plan approved');
|
||||||
|
} else {
|
||||||
|
toast.info('Plan rejected');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to submit plan decision', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a follow-up prompt to a feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for sending follow-up
|
||||||
|
*/
|
||||||
|
export function useFollowUpFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
featureId,
|
||||||
|
prompt,
|
||||||
|
imagePaths,
|
||||||
|
useWorktrees,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
prompt: string;
|
||||||
|
imagePaths?: string[];
|
||||||
|
useWorktrees?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.followUpFeature(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
prompt,
|
||||||
|
imagePaths,
|
||||||
|
useWorktrees
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to send follow-up');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to send follow-up', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit feature changes
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for committing feature
|
||||||
|
*/
|
||||||
|
export function useCommitFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (featureId: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.commitFeature(projectPath, featureId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to commit changes');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
|
||||||
|
toast.success('Changes committed');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to commit changes', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze project structure
|
||||||
|
*
|
||||||
|
* @returns Mutation for project analysis
|
||||||
|
*/
|
||||||
|
export function useAnalyzeProject() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (projectPath: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.analyzeProject(projectPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to analyze project');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Project analysis started');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to analyze project', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto mode for all pending features
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for starting auto mode
|
||||||
|
*/
|
||||||
|
export function useStartAutoMode(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (maxConcurrency?: number) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.start(projectPath, maxConcurrency);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start auto mode');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
toast.success('Auto mode started');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to start auto mode', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto mode for all features
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for stopping auto mode
|
||||||
|
*/
|
||||||
|
export function useStopAutoMode(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.autoMode.stop(projectPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to stop auto mode');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.runningAgents.all() });
|
||||||
|
toast.success('Auto mode stopped');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to stop auto mode', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Cursor Permissions Mutation Hooks
|
||||||
|
*
|
||||||
|
* React Query mutations for managing Cursor CLI permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ApplyProfileInput {
|
||||||
|
profileId: 'strict' | 'development';
|
||||||
|
scope: 'global' | 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a Cursor permission profile
|
||||||
|
*
|
||||||
|
* @param projectPath - Optional path to the project (required for project scope)
|
||||||
|
* @returns Mutation for applying permission profiles
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const applyMutation = useApplyCursorProfile(projectPath);
|
||||||
|
* applyMutation.mutate({ profileId: 'development', scope: 'project' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useApplyCursorProfile(projectPath?: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: ApplyProfileInput) => {
|
||||||
|
const { profileId, scope } = input;
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.setup.applyCursorPermissionProfile(
|
||||||
|
profileId,
|
||||||
|
scope,
|
||||||
|
scope === 'project' ? projectPath : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to apply profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// Invalidate permissions cache
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.cursorPermissions.permissions(projectPath),
|
||||||
|
});
|
||||||
|
toast.success(result.message || 'Profile applied');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to apply profile', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy Cursor example config to clipboard
|
||||||
|
*
|
||||||
|
* @returns Mutation for copying config
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const copyMutation = useCopyCursorConfig();
|
||||||
|
* copyMutation.mutate('development');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCopyCursorConfig() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (profileId: 'strict' | 'development') => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.setup.getCursorExampleConfig(profileId);
|
||||||
|
|
||||||
|
if (!result.success || !result.config) {
|
||||||
|
throw new Error(result.error || 'Failed to get config');
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(result.config);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Config copied to clipboard');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to copy config', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
267
apps/ui/src/hooks/mutations/use-feature-mutations.ts
Normal file
267
apps/ui/src/hooks/mutations/use-feature-mutations.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* Feature Mutations
|
||||||
|
*
|
||||||
|
* React Query mutations for creating, updating, and deleting features.
|
||||||
|
* Includes optimistic updates for better UX.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for creating a feature
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const createFeature = useCreateFeature(projectPath);
|
||||||
|
* createFeature.mutate({ id: 'uuid', title: 'New Feature', ... });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCreateFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (feature: Feature) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.create(projectPath, feature);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to create feature');
|
||||||
|
}
|
||||||
|
return result.feature;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
toast.success('Feature created');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to create feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for updating a feature with optimistic updates
|
||||||
|
*/
|
||||||
|
export function useUpdateFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
featureId,
|
||||||
|
updates,
|
||||||
|
descriptionHistorySource,
|
||||||
|
enhancementMode,
|
||||||
|
preEnhancementDescription,
|
||||||
|
}: {
|
||||||
|
featureId: string;
|
||||||
|
updates: Partial<Feature>;
|
||||||
|
descriptionHistorySource?: 'enhance' | 'edit';
|
||||||
|
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
|
||||||
|
preEnhancementDescription?: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.update(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
updates,
|
||||||
|
descriptionHistorySource,
|
||||||
|
enhancementMode,
|
||||||
|
preEnhancementDescription
|
||||||
|
);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to update feature');
|
||||||
|
}
|
||||||
|
return result.feature;
|
||||||
|
},
|
||||||
|
// Optimistic update
|
||||||
|
onMutate: async ({ featureId, updates }) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optimistically update the cache
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath),
|
||||||
|
previousFeatures.map((f) => (f.id === featureId ? { ...f, ...updates } : f))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousFeatures };
|
||||||
|
},
|
||||||
|
onError: (error: Error, _, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
|
||||||
|
}
|
||||||
|
toast.error('Failed to update feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
// Always refetch after error or success
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for deleting a feature with optimistic updates
|
||||||
|
*/
|
||||||
|
export function useDeleteFeature(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (featureId: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.delete(projectPath, featureId);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to delete feature');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Optimistic delete
|
||||||
|
onMutate: async (featureId) => {
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousFeatures) {
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath),
|
||||||
|
previousFeatures.filter((f) => f.id !== featureId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousFeatures };
|
||||||
|
},
|
||||||
|
onError: (error: Error, _, context) => {
|
||||||
|
if (context?.previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
|
||||||
|
}
|
||||||
|
toast.error('Failed to delete feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Feature deleted');
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a title for a feature description
|
||||||
|
*
|
||||||
|
* @returns Mutation for generating a title
|
||||||
|
*/
|
||||||
|
export function useGenerateTitle() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (description: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.generateTitle(description);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to generate title');
|
||||||
|
}
|
||||||
|
return result.title ?? '';
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to generate title', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch update multiple features (for reordering)
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for batch updating features
|
||||||
|
*/
|
||||||
|
export function useBatchUpdateFeatures(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (updates: Array<{ featureId: string; updates: Partial<Feature> }>) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const results = await Promise.all(
|
||||||
|
updates.map(({ featureId, updates: featureUpdates }) =>
|
||||||
|
api.features?.update(projectPath, featureId, featureUpdates)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const failed = results.filter((r) => !r?.success);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
throw new Error(`Failed to update ${failed.length} features`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Optimistic batch update
|
||||||
|
onMutate: async (updates) => {
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousFeatures = queryClient.getQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousFeatures) {
|
||||||
|
const updatesMap = new Map(updates.map((u) => [u.featureId, u.updates]));
|
||||||
|
queryClient.setQueryData<Feature[]>(
|
||||||
|
queryKeys.features.all(projectPath),
|
||||||
|
previousFeatures.map((f) => {
|
||||||
|
const featureUpdates = updatesMap.get(f.id);
|
||||||
|
return featureUpdates ? { ...f, ...featureUpdates } : f;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousFeatures };
|
||||||
|
},
|
||||||
|
onError: (error: Error, _, context) => {
|
||||||
|
if (context?.previousFeatures) {
|
||||||
|
queryClient.setQueryData(queryKeys.features.all(projectPath), context.previousFeatures);
|
||||||
|
}
|
||||||
|
toast.error('Failed to update features', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
163
apps/ui/src/hooks/mutations/use-github-mutations.ts
Normal file
163
apps/ui/src/hooks/mutations/use-github-mutations.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* GitHub Mutation Hooks
|
||||||
|
*
|
||||||
|
* React Query mutations for GitHub operations like validating issues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI, GitHubIssue, GitHubComment } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { LinkedPRInfo, ModelId } from '@automaker/types';
|
||||||
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for validating a GitHub issue
|
||||||
|
*/
|
||||||
|
interface ValidateIssueInput {
|
||||||
|
issue: GitHubIssue;
|
||||||
|
model?: ModelId;
|
||||||
|
thinkingLevel?: number;
|
||||||
|
reasoningEffort?: string;
|
||||||
|
comments?: GitHubComment[];
|
||||||
|
linkedPRs?: LinkedPRInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a GitHub issue with AI
|
||||||
|
*
|
||||||
|
* This mutation triggers an async validation process. Results are delivered
|
||||||
|
* via WebSocket events (issue_validation_complete, issue_validation_error).
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for validating issues
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const validateMutation = useValidateIssue(projectPath);
|
||||||
|
*
|
||||||
|
* validateMutation.mutate({
|
||||||
|
* issue,
|
||||||
|
* model: 'sonnet',
|
||||||
|
* comments,
|
||||||
|
* linkedPRs,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useValidateIssue(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: ValidateIssueInput) => {
|
||||||
|
const { issue, model, thinkingLevel, reasoningEffort, comments, linkedPRs } = input;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github?.validateIssue) {
|
||||||
|
throw new Error('Validation API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationInput = {
|
||||||
|
issueNumber: issue.number,
|
||||||
|
issueTitle: issue.title,
|
||||||
|
issueBody: issue.body || '',
|
||||||
|
issueLabels: issue.labels.map((l) => l.name),
|
||||||
|
comments,
|
||||||
|
linkedPRs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve model alias to canonical model identifier
|
||||||
|
const resolvedModel = model ? resolveModelString(model) : undefined;
|
||||||
|
|
||||||
|
const result = await api.github.validateIssue(
|
||||||
|
projectPath,
|
||||||
|
validationInput,
|
||||||
|
resolvedModel,
|
||||||
|
thinkingLevel,
|
||||||
|
reasoningEffort
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start validation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issueNumber: issue.number };
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
toast.info(`Starting validation for issue #${variables.issue.number}`, {
|
||||||
|
description: 'You will be notified when the analysis is complete',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to validate issue', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Note: We don't invalidate queries here because the actual result
|
||||||
|
// comes through WebSocket events which handle cache invalidation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a validation as viewed
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for marking validation as viewed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const markViewedMutation = useMarkValidationViewed(projectPath);
|
||||||
|
* markViewedMutation.mutate(issueNumber);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMarkValidationViewed(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (issueNumber: number) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github?.markValidationViewed) {
|
||||||
|
throw new Error('Mark viewed API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.github.markValidationViewed(projectPath, issueNumber);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to mark as viewed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issueNumber };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate validations cache to refresh the viewed state
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.github.validations(projectPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Silent mutation - no toast needed for marking as viewed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get running validation status
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for getting validation status (returns running issue numbers)
|
||||||
|
*/
|
||||||
|
export function useGetValidationStatus(projectPath: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github?.getValidationStatus) {
|
||||||
|
throw new Error('Validation status API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.github.getValidationStatus(projectPath);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to get validation status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.runningIssues ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
82
apps/ui/src/hooks/mutations/use-ideation-mutations.ts
Normal file
82
apps/ui/src/hooks/mutations/use-ideation-mutations.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Ideation Mutation Hooks
|
||||||
|
*
|
||||||
|
* React Query mutations for ideation operations like generating suggestions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { IdeaCategory, IdeaSuggestion } from '@automaker/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for generating ideation suggestions
|
||||||
|
*/
|
||||||
|
interface GenerateSuggestionsInput {
|
||||||
|
promptId: string;
|
||||||
|
category: IdeaCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from generating suggestions
|
||||||
|
*/
|
||||||
|
interface GenerateSuggestionsResult {
|
||||||
|
suggestions: IdeaSuggestion[];
|
||||||
|
promptId: string;
|
||||||
|
category: IdeaCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate ideation suggestions based on a prompt
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for generating suggestions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const generateMutation = useGenerateIdeationSuggestions(projectPath);
|
||||||
|
*
|
||||||
|
* generateMutation.mutate({
|
||||||
|
* promptId: 'prompt-1',
|
||||||
|
* category: 'ux',
|
||||||
|
* }, {
|
||||||
|
* onSuccess: (data) => {
|
||||||
|
* console.log('Generated', data.suggestions.length, 'suggestions');
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useGenerateIdeationSuggestions(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: GenerateSuggestionsInput): Promise<GenerateSuggestionsResult> => {
|
||||||
|
const { promptId, category } = input;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.ideation?.generateSuggestions) {
|
||||||
|
throw new Error('Ideation API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.ideation.generateSuggestions(projectPath, promptId, category);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to generate suggestions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: result.suggestions ?? [],
|
||||||
|
promptId,
|
||||||
|
category,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate ideation ideas cache
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.ideation.ideas(projectPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Toast notifications are handled by the component since it has access to prompt title
|
||||||
|
});
|
||||||
|
}
|
||||||
144
apps/ui/src/hooks/mutations/use-settings-mutations.ts
Normal file
144
apps/ui/src/hooks/mutations/use-settings-mutations.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Settings Mutations
|
||||||
|
*
|
||||||
|
* React Query mutations for updating global and project settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface UpdateGlobalSettingsOptions {
|
||||||
|
/** Show success toast (default: true) */
|
||||||
|
showSuccessToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update global settings
|
||||||
|
*
|
||||||
|
* @param options - Configuration options
|
||||||
|
* @returns Mutation for updating global settings
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const mutation = useUpdateGlobalSettings();
|
||||||
|
* mutation.mutate({ enableSkills: true });
|
||||||
|
*
|
||||||
|
* // With custom success handling (no default toast)
|
||||||
|
* const mutation = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||||
|
* mutation.mutate({ enableSkills: true }, {
|
||||||
|
* onSuccess: () => toast.success('Skills enabled'),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useUpdateGlobalSettings(options: UpdateGlobalSettingsOptions = {}) {
|
||||||
|
const { showSuccessToast = true } = options;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (settings: Record<string, unknown>) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
// Use updateGlobal for partial updates
|
||||||
|
const result = await api.settings.updateGlobal(settings);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to update settings');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.global() });
|
||||||
|
if (showSuccessToast) {
|
||||||
|
toast.success('Settings saved');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to save settings', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update project settings
|
||||||
|
*
|
||||||
|
* @param projectPath - Optional path to the project (can also pass via mutation variables)
|
||||||
|
* @returns Mutation for updating project settings
|
||||||
|
*/
|
||||||
|
export function useUpdateProjectSettings(projectPath?: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (
|
||||||
|
variables:
|
||||||
|
| Record<string, unknown>
|
||||||
|
| { projectPath: string; settings: Record<string, unknown> }
|
||||||
|
) => {
|
||||||
|
// Support both call patterns:
|
||||||
|
// 1. useUpdateProjectSettings(projectPath) then mutate(settings)
|
||||||
|
// 2. useUpdateProjectSettings() then mutate({ projectPath, settings })
|
||||||
|
let path: string;
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
|
||||||
|
if ('projectPath' in variables && 'settings' in variables) {
|
||||||
|
path = variables.projectPath;
|
||||||
|
settings = variables.settings;
|
||||||
|
} else if (projectPath) {
|
||||||
|
path = projectPath;
|
||||||
|
settings = variables;
|
||||||
|
} else {
|
||||||
|
throw new Error('Project path is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.settings.setProject(path, settings);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to update project settings');
|
||||||
|
}
|
||||||
|
return { ...result, projectPath: path };
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const path = data.projectPath || projectPath;
|
||||||
|
if (path) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.project(path) });
|
||||||
|
}
|
||||||
|
toast.success('Project settings saved');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to save project settings', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save credentials (API keys)
|
||||||
|
*
|
||||||
|
* @returns Mutation for saving credentials
|
||||||
|
*/
|
||||||
|
export function useSaveCredentials() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (credentials: Record<string, string>) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.settings.setCredentials(credentials);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to save credentials');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.credentials() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.cli.apiKeys() });
|
||||||
|
toast.success('Credentials saved');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to save credentials', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
184
apps/ui/src/hooks/mutations/use-spec-mutations.ts
Normal file
184
apps/ui/src/hooks/mutations/use-spec-mutations.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Spec Mutation Hooks
|
||||||
|
*
|
||||||
|
* React Query mutations for spec operations like creating, regenerating, and saving.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { FeatureCount } from '@/components/views/spec-view/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for creating a spec
|
||||||
|
*/
|
||||||
|
interface CreateSpecInput {
|
||||||
|
projectOverview: string;
|
||||||
|
generateFeatures: boolean;
|
||||||
|
analyzeProject: boolean;
|
||||||
|
featureCount?: FeatureCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for regenerating a spec
|
||||||
|
*/
|
||||||
|
interface RegenerateSpecInput {
|
||||||
|
projectDefinition: string;
|
||||||
|
generateFeatures: boolean;
|
||||||
|
analyzeProject: boolean;
|
||||||
|
featureCount?: FeatureCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new spec for a project
|
||||||
|
*
|
||||||
|
* This mutation triggers an async spec creation process. Progress and completion
|
||||||
|
* are delivered via WebSocket events (spec_regeneration_progress, spec_regeneration_complete).
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for creating specs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const createMutation = useCreateSpec(projectPath);
|
||||||
|
*
|
||||||
|
* createMutation.mutate({
|
||||||
|
* projectOverview: 'A todo app with...',
|
||||||
|
* generateFeatures: true,
|
||||||
|
* analyzeProject: true,
|
||||||
|
* featureCount: 50,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCreateSpec(projectPath: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: CreateSpecInput) => {
|
||||||
|
const { projectOverview, generateFeatures, analyzeProject, featureCount } = input;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) {
|
||||||
|
throw new Error('Spec regeneration API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.specRegeneration.create(
|
||||||
|
projectPath,
|
||||||
|
projectOverview.trim(),
|
||||||
|
generateFeatures,
|
||||||
|
analyzeProject,
|
||||||
|
generateFeatures ? featureCount : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start spec creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
// Toast/state updates are handled by the component since it tracks WebSocket events
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an existing spec
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for regenerating specs
|
||||||
|
*/
|
||||||
|
export function useRegenerateSpec(projectPath: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: RegenerateSpecInput) => {
|
||||||
|
const { projectDefinition, generateFeatures, analyzeProject, featureCount } = input;
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) {
|
||||||
|
throw new Error('Spec regeneration API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.specRegeneration.generate(
|
||||||
|
projectPath,
|
||||||
|
projectDefinition.trim(),
|
||||||
|
generateFeatures,
|
||||||
|
analyzeProject,
|
||||||
|
generateFeatures ? featureCount : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start spec regeneration');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate features from existing spec
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for generating features
|
||||||
|
*/
|
||||||
|
export function useGenerateFeatures(projectPath: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.specRegeneration) {
|
||||||
|
throw new Error('Spec regeneration API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.specRegeneration.generateFeatures(projectPath);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start feature generation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save spec file content
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for saving spec
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const saveMutation = useSaveSpec(projectPath);
|
||||||
|
*
|
||||||
|
* saveMutation.mutate(specContent, {
|
||||||
|
* onSuccess: () => setHasChanges(false),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSaveSpec(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
// Guard against empty projectPath to prevent writing to invalid locations
|
||||||
|
if (!projectPath || projectPath.trim() === '') {
|
||||||
|
throw new Error('Invalid project path: cannot save spec without a valid project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
await api.writeFile(`${projectPath}/.automaker/app_spec.txt`, content);
|
||||||
|
|
||||||
|
return { content };
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate spec file cache
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.spec.file(projectPath),
|
||||||
|
});
|
||||||
|
toast.success('Spec saved');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to save spec', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
480
apps/ui/src/hooks/mutations/use-worktree-mutations.ts
Normal file
480
apps/ui/src/hooks/mutations/use-worktree-mutations.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* Worktree Mutations
|
||||||
|
*
|
||||||
|
* React Query mutations for worktree operations like creating, deleting,
|
||||||
|
* committing, pushing, and creating pull requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new worktree
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for creating a worktree
|
||||||
|
*/
|
||||||
|
export function useCreateWorktree(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ branchName, baseBranch }: { branchName: string; baseBranch?: string }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.create(projectPath, branchName, baseBranch);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to create worktree');
|
||||||
|
}
|
||||||
|
return result.worktree;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
|
||||||
|
toast.success('Worktree created');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to create worktree', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a worktree
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for deleting a worktree
|
||||||
|
*/
|
||||||
|
export function useDeleteWorktree(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
deleteBranch,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
deleteBranch?: boolean;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.delete(projectPath, worktreePath, deleteBranch);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to delete worktree');
|
||||||
|
}
|
||||||
|
return result.deleted;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
|
||||||
|
toast.success('Worktree deleted');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to delete worktree', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit changes in a worktree
|
||||||
|
*
|
||||||
|
* @returns Mutation for committing changes
|
||||||
|
*/
|
||||||
|
export function useCommitWorktree() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ worktreePath, message }: { worktreePath: string; message: string }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.commit(worktreePath, message);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to commit changes');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { worktreePath }) => {
|
||||||
|
// Invalidate all worktree queries since we don't know the project path
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Changes committed');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to commit changes', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push worktree branch to remote
|
||||||
|
*
|
||||||
|
* @returns Mutation for pushing changes
|
||||||
|
*/
|
||||||
|
export function usePushWorktree() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.push(worktreePath, force);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to push changes');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Changes pushed to remote');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to push changes', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull changes from remote
|
||||||
|
*
|
||||||
|
* @returns Mutation for pulling changes
|
||||||
|
*/
|
||||||
|
export function usePullWorktree() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (worktreePath: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.pull(worktreePath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to pull changes');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Changes pulled from remote');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to pull changes', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pull request from a worktree
|
||||||
|
*
|
||||||
|
* @returns Mutation for creating a PR
|
||||||
|
*/
|
||||||
|
export function useCreatePullRequest() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
options?: {
|
||||||
|
projectPath?: string;
|
||||||
|
commitMessage?: string;
|
||||||
|
prTitle?: string;
|
||||||
|
prBody?: string;
|
||||||
|
baseBranch?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.createPR(worktreePath, options);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to create pull request');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['github', 'prs'] });
|
||||||
|
if (result?.prUrl) {
|
||||||
|
toast.success('Pull request created', {
|
||||||
|
description: `PR #${result.prNumber} created`,
|
||||||
|
action: {
|
||||||
|
label: 'Open',
|
||||||
|
onClick: () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink(result.prUrl!);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (result?.prAlreadyExisted) {
|
||||||
|
toast.info('Pull request already exists');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to create pull request', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a worktree branch into main
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for merging a feature
|
||||||
|
*/
|
||||||
|
export function useMergeWorktree(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
branchName,
|
||||||
|
worktreePath,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
branchName: string;
|
||||||
|
worktreePath: string;
|
||||||
|
options?: {
|
||||||
|
squash?: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.mergeFeature(
|
||||||
|
projectPath,
|
||||||
|
branchName,
|
||||||
|
worktreePath,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to merge feature');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.all(projectPath) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
|
||||||
|
toast.success('Feature merged successfully');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to merge feature', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different branch
|
||||||
|
*
|
||||||
|
* @returns Mutation for switching branches
|
||||||
|
*/
|
||||||
|
export function useSwitchBranch() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.switchBranch(worktreePath, branchName);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to switch branch');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('Switched branch');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to switch branch', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout a new branch
|
||||||
|
*
|
||||||
|
* @returns Mutation for creating and checking out a new branch
|
||||||
|
*/
|
||||||
|
export function useCheckoutBranch() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
branchName,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
branchName: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.checkoutBranch(worktreePath, branchName);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to checkout branch');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
toast.success('New branch created and checked out');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to checkout branch', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a commit message from git diff
|
||||||
|
*
|
||||||
|
* @returns Mutation for generating a commit message
|
||||||
|
*/
|
||||||
|
export function useGenerateCommitMessage() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (worktreePath: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.generateCommitMessage(worktreePath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to generate commit message');
|
||||||
|
}
|
||||||
|
return result.message ?? '';
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to generate commit message', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open worktree in editor
|
||||||
|
*
|
||||||
|
* @returns Mutation for opening in editor
|
||||||
|
*/
|
||||||
|
export function useOpenInEditor() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
worktreePath,
|
||||||
|
editorCommand,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
editorCommand?: string;
|
||||||
|
}) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.openInEditor(worktreePath, editorCommand);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to open in editor');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to open in editor', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize git in a project
|
||||||
|
*
|
||||||
|
* @returns Mutation for initializing git
|
||||||
|
*/
|
||||||
|
export function useInitGit() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (projectPath: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.initGit(projectPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to initialize git');
|
||||||
|
}
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['github'] });
|
||||||
|
toast.success('Git repository initialized');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to initialize git', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set init script for a project
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for setting init script
|
||||||
|
*/
|
||||||
|
export function useSetInitScript(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.setInitScript(projectPath, content);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to save init script');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) });
|
||||||
|
toast.success('Init script saved');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to save init script', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete init script for a project
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Mutation for deleting init script
|
||||||
|
*/
|
||||||
|
export function useDeleteInitScript(projectPath: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.worktree.deleteInitScript(projectPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to delete init script');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.worktrees.initScript(projectPath) });
|
||||||
|
toast.success('Init script deleted');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error('Failed to delete init script', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
91
apps/ui/src/hooks/queries/index.ts
Normal file
91
apps/ui/src/hooks/queries/index.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Query Hooks Barrel Export
|
||||||
|
*
|
||||||
|
* Central export point for all React Query hooks.
|
||||||
|
* Import from this file for cleaner imports across the app.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { useFeatures, useGitHubIssues, useClaudeUsage } from '@/hooks/queries';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Features
|
||||||
|
export { useFeatures, useFeature, useAgentOutput } from './use-features';
|
||||||
|
|
||||||
|
// GitHub
|
||||||
|
export {
|
||||||
|
useGitHubIssues,
|
||||||
|
useGitHubPRs,
|
||||||
|
useGitHubValidations,
|
||||||
|
useGitHubRemote,
|
||||||
|
useGitHubIssueComments,
|
||||||
|
} from './use-github';
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
export { useClaudeUsage, useCodexUsage } from './use-usage';
|
||||||
|
|
||||||
|
// Running Agents
|
||||||
|
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
|
||||||
|
|
||||||
|
// Worktrees
|
||||||
|
export {
|
||||||
|
useWorktrees,
|
||||||
|
useWorktreeInfo,
|
||||||
|
useWorktreeStatus,
|
||||||
|
useWorktreeDiffs,
|
||||||
|
useWorktreeBranches,
|
||||||
|
useWorktreeInitScript,
|
||||||
|
useAvailableEditors,
|
||||||
|
} from './use-worktrees';
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
export {
|
||||||
|
useGlobalSettings,
|
||||||
|
useProjectSettings,
|
||||||
|
useSettingsStatus,
|
||||||
|
useCredentials,
|
||||||
|
useDiscoveredAgents,
|
||||||
|
} from './use-settings';
|
||||||
|
|
||||||
|
// Models
|
||||||
|
export {
|
||||||
|
useAvailableModels,
|
||||||
|
useCodexModels,
|
||||||
|
useOpencodeModels,
|
||||||
|
useOpencodeProviders,
|
||||||
|
useModelProviders,
|
||||||
|
} from './use-models';
|
||||||
|
|
||||||
|
// CLI Status
|
||||||
|
export {
|
||||||
|
useClaudeCliStatus,
|
||||||
|
useCursorCliStatus,
|
||||||
|
useCodexCliStatus,
|
||||||
|
useOpencodeCliStatus,
|
||||||
|
useGitHubCliStatus,
|
||||||
|
useApiKeysStatus,
|
||||||
|
usePlatformInfo,
|
||||||
|
} from './use-cli-status';
|
||||||
|
|
||||||
|
// Ideation
|
||||||
|
export { useIdeationPrompts, useIdeas, useIdea } from './use-ideation';
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
export { useSessions, useSessionHistory, useSessionQueue } from './use-sessions';
|
||||||
|
|
||||||
|
// Git
|
||||||
|
export { useGitDiffs } from './use-git';
|
||||||
|
|
||||||
|
// Pipeline
|
||||||
|
export { usePipelineConfig } from './use-pipeline';
|
||||||
|
|
||||||
|
// Spec
|
||||||
|
export { useSpecFile, useSpecRegenerationStatus } from './use-spec';
|
||||||
|
|
||||||
|
// Cursor Permissions
|
||||||
|
export { useCursorPermissionsQuery } from './use-cursor-permissions';
|
||||||
|
export type { CursorPermissionsData } from './use-cursor-permissions';
|
||||||
|
|
||||||
|
// Workspace
|
||||||
|
export { useWorkspaceDirectories } from './use-workspace';
|
||||||
147
apps/ui/src/hooks/queries/use-cli-status.ts
Normal file
147
apps/ui/src/hooks/queries/use-cli-status.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* CLI Status Query Hooks
|
||||||
|
*
|
||||||
|
* React Query hooks for fetching CLI tool status (Claude, Cursor, Codex, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Claude CLI status
|
||||||
|
*
|
||||||
|
* @returns Query result with Claude CLI status
|
||||||
|
*/
|
||||||
|
export function useClaudeCliStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.claude(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getClaudeStatus();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch Claude status');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Cursor CLI status
|
||||||
|
*
|
||||||
|
* @returns Query result with Cursor CLI status
|
||||||
|
*/
|
||||||
|
export function useCursorCliStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.cursor(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getCursorStatus();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch Cursor status');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Codex CLI status
|
||||||
|
*
|
||||||
|
* @returns Query result with Codex CLI status
|
||||||
|
*/
|
||||||
|
export function useCodexCliStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.codex(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getCodexStatus();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch Codex status');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch OpenCode CLI status
|
||||||
|
*
|
||||||
|
* @returns Query result with OpenCode CLI status
|
||||||
|
*/
|
||||||
|
export function useOpencodeCliStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.opencode(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getOpencodeStatus();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch OpenCode status');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch GitHub CLI status
|
||||||
|
*
|
||||||
|
* @returns Query result with GitHub CLI status
|
||||||
|
*/
|
||||||
|
export function useGitHubCliStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.github(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getGhStatus();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch GitHub CLI status');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch API keys status
|
||||||
|
*
|
||||||
|
* @returns Query result with API keys status
|
||||||
|
*/
|
||||||
|
export function useApiKeysStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.apiKeys(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getApiKeys();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: STALE_TIMES.CLI_STATUS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch platform info
|
||||||
|
*
|
||||||
|
* @returns Query result with platform info
|
||||||
|
*/
|
||||||
|
export function usePlatformInfo() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cli.platform(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.setup.getPlatform();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error('Failed to fetch platform info');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
staleTime: Infinity, // Platform info never changes
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/ui/src/hooks/queries/use-cursor-permissions.ts
Normal file
58
apps/ui/src/hooks/queries/use-cursor-permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Cursor Permissions Query Hooks
|
||||||
|
*
|
||||||
|
* React Query hooks for fetching Cursor CLI permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
import type { CursorPermissionProfile } from '@automaker/types';
|
||||||
|
|
||||||
|
export interface CursorPermissionsData {
|
||||||
|
activeProfile: CursorPermissionProfile | null;
|
||||||
|
effectivePermissions: { allow: string[]; deny: string[] } | null;
|
||||||
|
hasProjectConfig: boolean;
|
||||||
|
availableProfiles: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: { allow: string[]; deny: string[] };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Cursor permissions for a project
|
||||||
|
*
|
||||||
|
* @param projectPath - Optional path to the project
|
||||||
|
* @param enabled - Whether to enable the query
|
||||||
|
* @returns Query result with permissions data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { data: permissions, isLoading, refetch } = useCursorPermissions(projectPath);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCursorPermissionsQuery(projectPath?: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.cursorPermissions.permissions(projectPath),
|
||||||
|
queryFn: async (): Promise<CursorPermissionsData> => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.setup.getCursorPermissions(projectPath);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to load permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeProfile: result.activeProfile || null,
|
||||||
|
effectivePermissions: result.effectivePermissions || null,
|
||||||
|
hasProjectConfig: result.hasProjectConfig || false,
|
||||||
|
availableProfiles: result.availableProfiles || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: STALE_TIMES.SETTINGS,
|
||||||
|
});
|
||||||
|
}
|
||||||
136
apps/ui/src/hooks/queries/use-features.ts
Normal file
136
apps/ui/src/hooks/queries/use-features.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Features Query Hooks
|
||||||
|
*
|
||||||
|
* React Query hooks for fetching and managing features data.
|
||||||
|
* These hooks replace manual useState/useEffect patterns with
|
||||||
|
* automatic caching, deduplication, and background refetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
import type { Feature } from '@/store/app-store';
|
||||||
|
|
||||||
|
const FEATURES_REFETCH_ON_FOCUS = false;
|
||||||
|
const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all features for a project
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @returns Query result with features array
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { data: features, isLoading, error } = useFeatures(currentProject?.path);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useFeatures(projectPath: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.features.all(projectPath ?? ''),
|
||||||
|
queryFn: async (): Promise<Feature[]> => {
|
||||||
|
if (!projectPath) throw new Error('No project path');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.getAll(projectPath);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to fetch features');
|
||||||
|
}
|
||||||
|
return (result.features ?? []) as Feature[];
|
||||||
|
},
|
||||||
|
enabled: !!projectPath,
|
||||||
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFeatureOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Override polling interval (ms). Use false to disable polling. */
|
||||||
|
pollingInterval?: number | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single feature by ID
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param featureId - ID of the feature to fetch
|
||||||
|
* @param options - Query options including enabled and polling interval
|
||||||
|
* @returns Query result with single feature
|
||||||
|
*/
|
||||||
|
export function useFeature(
|
||||||
|
projectPath: string | undefined,
|
||||||
|
featureId: string | undefined,
|
||||||
|
options: UseFeatureOptions = {}
|
||||||
|
) {
|
||||||
|
const { enabled = true, pollingInterval } = options;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.features.single(projectPath ?? '', featureId ?? ''),
|
||||||
|
queryFn: async (): Promise<Feature | null> => {
|
||||||
|
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.get(projectPath, featureId);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to fetch feature');
|
||||||
|
}
|
||||||
|
return (result.feature as Feature) ?? null;
|
||||||
|
},
|
||||||
|
enabled: !!projectPath && !!featureId && enabled,
|
||||||
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
|
refetchInterval: pollingInterval,
|
||||||
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAgentOutputOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Override polling interval (ms). Use false to disable polling. */
|
||||||
|
pollingInterval?: number | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch agent output for a feature
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param featureId - ID of the feature
|
||||||
|
* @param options - Query options including enabled and polling interval
|
||||||
|
* @returns Query result with agent output string
|
||||||
|
*/
|
||||||
|
export function useAgentOutput(
|
||||||
|
projectPath: string | undefined,
|
||||||
|
featureId: string | undefined,
|
||||||
|
options: UseAgentOutputOptions = {}
|
||||||
|
) {
|
||||||
|
const { enabled = true, pollingInterval } = options;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.features.agentOutput(projectPath ?? '', featureId ?? ''),
|
||||||
|
queryFn: async (): Promise<string> => {
|
||||||
|
if (!projectPath || !featureId) throw new Error('Missing project path or feature ID');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.features?.getAgentOutput(projectPath, featureId);
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || 'Failed to fetch agent output');
|
||||||
|
}
|
||||||
|
return result.content ?? '';
|
||||||
|
},
|
||||||
|
enabled: !!projectPath && !!featureId && enabled,
|
||||||
|
staleTime: STALE_TIMES.AGENT_OUTPUT,
|
||||||
|
// Use provided polling interval or default behavior
|
||||||
|
refetchInterval:
|
||||||
|
pollingInterval !== undefined
|
||||||
|
? pollingInterval
|
||||||
|
: (query) => {
|
||||||
|
// Only poll if we have data and it's not empty (indicating active task)
|
||||||
|
if (query.state.data && query.state.data.length > 0) {
|
||||||
|
return 5000; // 5 seconds
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
37
apps/ui/src/hooks/queries/use-git.ts
Normal file
37
apps/ui/src/hooks/queries/use-git.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Git Query Hooks
|
||||||
|
*
|
||||||
|
* React Query hooks for git operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch git diffs for a project (main project, not worktree)
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param enabled - Whether to enable the query
|
||||||
|
* @returns Query result with files and diff content
|
||||||
|
*/
|
||||||
|
export function useGitDiffs(projectPath: string | undefined, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.git.diffs(projectPath ?? ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!projectPath) throw new Error('No project path');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.git.getDiffs(projectPath);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch diffs');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
files: result.files ?? [],
|
||||||
|
diff: result.diff ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!projectPath && enabled,
|
||||||
|
staleTime: STALE_TIMES.WORKTREES,
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user