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:
Stefan de Vogelaere
2026-01-20 19:36:30 +01:00
133 changed files with 9283 additions and 3269 deletions

300
SECURITY_TODO.md Normal file
View 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

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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',

View File

@@ -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;
} }

View File

@@ -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) {

View 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) });
}
};
}

View File

@@ -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) });

View File

@@ -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) {

View File

@@ -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,
})), })),
}); });

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
}; };

View File

@@ -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]);

View 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)} />;
}

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 */}

View File

@@ -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}
/> />
); );
} });

View File

@@ -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>
); );
} });

View File

@@ -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>
); );
} });

View File

@@ -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
})()} })()}
</> </>
); );
} });

View File

@@ -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>
); );
} });

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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,
}; };
} }

View File

@@ -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(() => {

View File

@@ -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,
}; };

View File

@@ -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 {

View File

@@ -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>
); );
} }

View File

@@ -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"

View File

@@ -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}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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))

View File

@@ -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[];

View File

@@ -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>
); );
} }

View File

@@ -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>
</> </>

View File

@@ -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) {

View File

@@ -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,
}; };
} }

View File

@@ -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,
}; };

View File

@@ -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,
}; };
} }

View File

@@ -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();

View File

@@ -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 ?? '' });

View File

@@ -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 */}

View File

@@ -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) */}

View 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;

View File

@@ -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"
> >

View File

@@ -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 });

View File

@@ -54,16 +54,40 @@ 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;
} }
/** /**
@@ -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

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}; };
} }

View File

@@ -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,
}; };
} }

View File

@@ -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,
}; };
} }

View File

@@ -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}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View 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';

View 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,
});
},
});
}

View File

@@ -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',
});
},
});
}

View 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),
});
},
});
}

View 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 ?? [];
},
});
}

View 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
});
}

View 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,
});
},
});
}

View 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',
});
},
});
}

View 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,
});
},
});
}

View 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';

View 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
});
}

View 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,
});
}

View 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,
});
}

View 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