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.
# Performance (completed)
- [x] Graph performance mode for large graphs (compact nodes/edges + visible-only rendering)
- [x] Render containment on heavy scroll regions (kanban columns, chat history)
- [x] Reduce blur/shadow effects when lists get large
- [x] React Query tuning for heavy datasets (less refetch on focus/reconnect)
- [x] DnD/list rendering optimizations (virtualized kanban + memoized card sections)
# UX
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff

View File

@@ -249,7 +249,7 @@ notificationService.setEventEmitter(events);
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService);
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
// Initialize services
(async () => {

View File

@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
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
// executeFeature derives workDir from feature.branchName
autoModeService

View File

@@ -85,8 +85,9 @@ export function createApplyHandler() {
if (!change.feature) continue;
try {
// Create the new feature
// Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
title: change.feature.title,
description: change.feature.description || '',
category: change.feature.category || 'Uncategorized',

View File

@@ -49,6 +49,7 @@ import {
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -157,5 +158,13 @@ export function createWorktreeRoutes(
createDiscardChangesHandler()
);
// List remotes route
router.post(
'/list-remotes',
validatePathParams('worktreePath'),
requireValidWorktree,
createListRemotesHandler()
);
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 behindCount = 0;
let hasRemoteBranch = false;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync(
@@ -121,6 +122,7 @@ export function createListBranchesHandler() {
);
if (upstreamOutput.trim()) {
hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
{ cwd: worktreePath }
@@ -130,7 +132,18 @@ export function createListBranchesHandler() {
behindCount = behind || 0;
}
} 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({
@@ -140,6 +153,7 @@ export function createListBranchesHandler() {
branches,
aheadCount,
behindCount,
hasRemoteBranch,
},
});
} 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
* the requireValidProject middleware in index.ts
@@ -8,18 +10,21 @@
import type { Request, Response } from 'express';
import { exec } from 'child_process';
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 logger = createLogger('Worktree');
export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, worktreePath, options } = req.body as {
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
projectPath: string;
branchName: 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) {
@@ -30,7 +35,10 @@ export function createMergeHandler() {
return;
}
// Validate branch exists
// Determine the target branch (default to 'main')
const mergeTo = targetBranch || 'main';
// Validate source branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
@@ -41,12 +49,44 @@ export function createMergeHandler() {
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
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
await execAsync(mergeCmd, { cwd: projectPath });
try {
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 (options?.squash) {
@@ -55,17 +95,46 @@ export function createMergeHandler() {
});
}
// Clean up worktree and branch
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Cleanup errors are non-fatal
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
// 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 });
res.json({
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
} catch (error) {
logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, force } = req.body as {
const { worktreePath, force, remote } = req.body as {
worktreePath: string;
force?: boolean;
remote?: string;
};
if (!worktreePath) {
@@ -34,15 +35,18 @@ export function createPushHandler() {
});
const branchName = branchOutput.trim();
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Push the branch
const forceFlag = force ? '--force' : '';
try {
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
} catch {
// Try setting upstream
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath,
});
}
@@ -52,7 +56,7 @@ export function createPushHandler() {
result: {
branch: branchName,
pushed: true,
message: `Successfully pushed ${branchName} to origin`,
message: `Successfully pushed ${branchName} to ${targetRemote}`,
},
});
} catch (error) {

View File

@@ -249,7 +249,8 @@ interface AutoModeConfig {
* @param branchName - The branch name, or null for main worktree
*/
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
: DEFAULT_MAX_CONCURRENCY;
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
const autoModeByWorktree = (settings as unknown as Record<string, unknown>)
.autoModeByWorktree;
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const key = `${projectId}::${branchName ?? '__main__'}`;
const entry = (autoModeByWorktree as Record<string, unknown>)[key] as
| { maxConcurrency?: number }
| undefined;
const entry = autoModeByWorktree[key];
if (entry && typeof entry.maxConcurrency === 'number') {
return entry.maxConcurrency;
}
@@ -593,6 +591,7 @@ export class AutoModeService {
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
projectPath,
branchName,
maxConcurrency: resolvedMaxConcurrency,
});
// Save execution state for recovery after restart
@@ -678,8 +677,10 @@ export class AutoModeService {
continue;
}
// Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
// Find a feature not currently running and not yet finished
const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f)
);
if (nextFeature) {
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")
*/
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
const normalizedBranch = branchName === 'main' ? null : branchName;
let count = 0;
for (const [, feature] of this.runningFeatures) {
// Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null;
if (branchName === null) {
if (normalizedBranch === null) {
// Main worktree: match features with branchName === null OR branchName === "main"
if (
feature.projectPath === projectPath &&
@@ -999,6 +1001,41 @@ export class AutoModeService {
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
* @param projectPath - The main project path
@@ -1037,7 +1074,6 @@ export class AutoModeService {
if (isAutoMode) {
await this.saveExecutionState(projectPath);
}
// Declare feature outside try block so it's available in catch for error reporting
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
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
// Skip this check if we're already being called with a continuation prompt (from resumeFeature)
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);
if (hasExistingContext) {
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
// Worktrees should already be created when the feature is added/edited
let worktreePath: string | null = null;
@@ -1191,6 +1256,7 @@ export class AutoModeService {
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
}
);
@@ -1362,6 +1428,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName: feature.branchName ?? null,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
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
*/
@@ -2910,10 +2992,14 @@ Format your response as a structured markdown document.`;
allFeatures.push(feature);
// 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 (
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog'
feature.status === 'backlog' ||
(feature.planSpec?.status === 'approved' &&
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
) {
// Filter by branchName:
// - 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';
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) {
@@ -2954,7 +3040,12 @@ Format your response as a structured markdown document.`;
);
// Log all backlog features to help debug branchName matching
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) {
logger.info(
@@ -2964,7 +3055,43 @@ Format your response as a structured markdown document.`;
}
// 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
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;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
}
): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath;
const branchName = options?.branchName ?? null;
const planningMode = options?.planningMode || 'skip';
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', {
featureId,
projectPath,
branchName,
planContent: currentPlanContent,
planningMode,
planVersion,
@@ -3559,6 +3689,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approved', {
featureId,
projectPath,
branchName,
hasEdits: !!approvalResult.editedPlan,
planVersion,
});
@@ -3587,6 +3718,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_revision_requested', {
featureId,
projectPath,
branchName,
feedback: approvalResult.feedback,
hasEdits: !!hasEdits,
planVersion,
@@ -3690,6 +3822,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('plan_auto_approved', {
featureId,
projectPath,
branchName,
planContent,
planningMode,
});
@@ -3740,6 +3873,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_started', {
featureId,
projectPath,
branchName,
taskId: task.id,
taskDescription: task.description,
taskIndex,
@@ -3785,11 +3919,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -3808,6 +3944,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_complete', {
featureId,
projectPath,
branchName,
taskId: task.id,
tasksCompleted: taskIndex + 1,
tasksTotal: parsedTasks.length,
@@ -3828,6 +3965,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_phase_complete', {
featureId,
projectPath,
branchName,
phaseNumber: parseInt(phaseMatch[1], 10),
});
}
@@ -3877,11 +4015,13 @@ After generating the revised spec, output:
responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
} else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -3907,6 +4047,7 @@ After generating the revised spec, output:
);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
branchName,
content: block.text,
});
}
@@ -3914,6 +4055,7 @@ After generating the revised spec, output:
// Emit event for real-time UI
this.emitAutoModeEvent('auto_mode_tool', {
featureId,
branchName,
tool: block.name,
input: block.input,
});
@@ -4319,6 +4461,7 @@ After generating the revised spec, output:
id: f.id,
title: f.title,
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 { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js';
import type { FeatureLoader } from './feature-loader.js';
import type {
EventHook,
EventHookTrigger,
@@ -84,19 +85,22 @@ export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | 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(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
eventHistoryService?: EventHistoryService,
featureLoader?: FeatureLoader
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
this.featureLoader = featureLoader || null;
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
@@ -121,6 +125,7 @@ export class EventHookService {
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
this.featureLoader = null;
}
/**
@@ -150,6 +155,19 @@ export class EventHookService {
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
const context: HookContext = {
featureId: payload.featureId,
@@ -315,6 +333,7 @@ export class EventHookService {
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath,
projectName: context.projectName,
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
// Empty object overwrite guard
if (
sanitizedUpdates.lastSelectedSessionByProject &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
current.lastSelectedSessionByProject &&
Object.keys(current.lastSelectedSessionByProject).length > 0
) {
delete sanitizedUpdates.lastSelectedSessionByProject;
}
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if (
nextVal &&
typeof nextVal === 'object' &&
!Array.isArray(nextVal) &&
Object.keys(nextVal).length === 0 &&
curVal &&
typeof curVal === 'object' &&
!Array.isArray(curVal) &&
Object.keys(curVal).length > 0
) {
delete sanitizedUpdates[key];
}
};
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
ignoreEmptyObjectOverwrite('autoModeByWorktree');
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {

View File

@@ -80,7 +80,8 @@
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@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",
"@uiw/react-codemirror": "4.25.4",
"@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 { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// 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;
import { useClaudeUsage } from '@/hooks/queries';
export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
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(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
}, [claudeUsageLastUpdated]);
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]);
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
}, [dataUpdatedAt]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -144,7 +69,6 @@ export function ClaudeUsagePopover() {
isPrimary?: boolean;
stale?: boolean;
}) => {
// Check if percentage is valid (not NaN, not undefined, is a finite number)
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
@@ -245,10 +169,10 @@ export function ClaudeUsagePopover() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>
@@ -259,26 +183,16 @@ export function ClaudeUsagePopover() {
<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" />
<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">
{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{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</>
)}
Make sure Claude CLI is installed and authenticated via{' '}
<code className="font-mono bg-muted px-1 rounded">claude login</code>
</p>
</div>
</div>
) : !claudeUsage ? (
// Loading state
) : isLoading || !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>

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 { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -23,9 +22,6 @@ type UsageError = {
message: string;
};
// Fixed refresh interval (45 seconds)
const REFRESH_INTERVAL_SECONDS = 45;
// Helper to format reset time
function formatResetTime(unixTimestamp: number): string {
const date = new Date(unixTimestamp * 1000);
@@ -63,95 +59,39 @@ function getWindowLabel(durationMins: number): { title: string; subtitle: string
}
export function CodexUsagePopover() {
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if Codex is 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)
const isStale = useMemo(() => {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
return !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
}, [dataUpdatedAt]);
const fetchUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.codex) {
setError({
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
message: 'Codex API bridge not available',
});
return;
}
const data = await api.codex.getUsage();
if ('error' in data) {
// 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;
}
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);
// Convert query error to UsageError format for backward compatibility
const error = useMemo((): UsageError | null => {
if (!queryError) return null;
const message = queryError instanceof Error ? queryError.message : String(queryError);
if (message.includes('not available') || message.includes('does not provide')) {
return { code: ERROR_CODES.NOT_AVAILABLE, message };
}
}, [isStale, isCodexAuthenticated, fetchUsage]);
useEffect(() => {
// Skip if not authenticated
if (!isCodexAuthenticated) return;
// Initial fetch when opened
if (open) {
if (!codexUsage || isStale) {
fetchUsage();
}
if (message.includes('bridge') || message.includes('API')) {
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
}
// 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]);
return { code: ERROR_CODES.AUTH_ERROR, message };
}, [queryError]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -289,10 +229,10 @@ export function CodexUsagePopover() {
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', loading && 'opacity-80')}
onClick={() => !loading && fetchUsage(false)}
className={cn('h-6 w-6', isFetching && 'opacity-80')}
onClick={() => !isFetching && refetch()}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={cn('w-3.5 h-3.5', isFetching && 'animate-spin')} />
</Button>
)}
</div>

View File

@@ -1,4 +1,3 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -10,7 +9,7 @@ import {
import { Button } from '@/components/ui/button';
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory {
name: string;
@@ -24,41 +23,15 @@ interface WorkspacePickerModalProps {
}
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
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]);
// React Query hook - only fetch when modal is open
const { data: directories = [], isLoading, error, refetch } = useWorkspaceDirectories(open);
const handleSelect = (dir: WorkspaceDirectory) => {
onSelect(dir.path, dir.name);
};
const errorMessage = error instanceof Error ? error.message : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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>
)}
{error && !isLoading && (
{errorMessage && !isLoading && (
<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">
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<p className="text-sm text-destructive">{error}</p>
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
<p className="text-sm text-destructive">{errorMessage}</p>
<Button variant="secondary" size="sm" onClick={() => refetch()} className="mt-2">
Try Again
</Button>
</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="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
<Folder className="w-6 h-6 text-muted-foreground" />
@@ -103,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
</div>
)}
{!isLoading && !error && directories.length > 0 && (
{!isLoading && !errorMessage && directories.length > 0 && (
<div className="space-y-2">
{directories.map((dir) => (
<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 { useQueryClient } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
const logger = createLogger('SessionManager');
@@ -22,6 +23,8 @@ import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
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 { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
@@ -102,7 +105,7 @@ export function SessionManager({
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
@@ -113,8 +116,14 @@ export function SessionManager({
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
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
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
const api = getElectronAPI();
if (!api?.agent) return;
@@ -134,26 +143,26 @@ export function SessionManager({
}
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)
useEffect(() => {
// Only poll if there are running sessions
@@ -166,7 +175,7 @@ export function SessionManager({
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
// Create new session with random name
const handleCreateSession = async () => {
@@ -180,7 +189,7 @@ export function SessionManager({
if (result.success && result.session?.id) {
setNewSessionName('');
setIsCreating(false);
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -195,7 +204,7 @@ export function SessionManager({
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
await loadSessions();
await invalidateSessions();
onSelectSession(result.session.id);
}
};
@@ -222,7 +231,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName('');
await loadSessions();
await invalidateSessions();
}
};
@@ -241,7 +250,7 @@ export function SessionManager({
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Archive failed:', result.error);
}
@@ -261,7 +270,7 @@ export function SessionManager({
try {
const result = await api.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
} else {
logger.error('[SessionManager] Unarchive failed:', result.error);
}
@@ -283,7 +292,7 @@ export function SessionManager({
const result = await api.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
await invalidateSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
@@ -305,7 +314,7 @@ export function SessionManager({
await api.sessions.delete(session.id);
}
await loadSessions();
await invalidateSessions();
setIsDeleteAllArchivedDialogOpen(false);
};

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
File,
@@ -15,6 +14,7 @@ import {
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
@@ -350,56 +350,44 @@ export function GitDiffPanel({
useWorktrees = false,
}: GitDiffPanelProps) {
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 loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
// Use worktree diffs hook when worktrees are enabled and panel is expanded
// Pass undefined for featureId when not using worktrees to disable the query
const {
data: worktreeDiffsData,
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
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error('Worktree API not available');
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
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]);
// Use git diffs hook when worktrees are disabled and panel is expanded
const {
data: gitDiffsData,
isLoading: isLoadingGit,
error: gitError,
refetch: refetchGit,
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
// Select the appropriate data based on useWorktrees prop
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
const queryError = useWorktrees ? worktreeError : gitError;
// 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]);

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 { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -61,22 +60,63 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
}
export function UsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
const [open, setOpen] = useState(false);
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
const isClaudeAuthenticated = !!claudeAuthStatus?.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
useEffect(() => {
if (isClaudeAuthenticated) {
@@ -95,137 +135,9 @@ export function UsagePopover() {
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
}, [codexUsageLastUpdated]);
const fetchClaudeUsage = useCallback(
async (isAutoRefresh = false) => {
if (!isAutoRefresh) setClaudeLoading(true);
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,
]);
// Refetch functions for manual refresh
const fetchClaudeUsage = () => refetchClaude();
const fetchCodexUsage = () => refetchCodex();
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {
@@ -417,7 +329,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', claudeLoading && 'opacity-80')}
onClick={() => !claudeLoading && fetchClaudeUsage(false)}
onClick={() => !claudeLoading && fetchClaudeUsage()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
@@ -524,7 +436,7 @@ export function UsagePopover() {
variant="ghost"
size="icon"
className={cn('h-6 w-6', codexLoading && 'opacity-80')}
onClick={() => !codexLoading && fetchCodexUsage(false)}
onClick={() => !codexLoading && fetchCodexUsage()}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>

View File

@@ -1,7 +1,9 @@
import { useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useQueryClient } from '@tanstack/react-query';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
@@ -72,6 +74,7 @@ export function AnalysisView() {
const [isGeneratingFeatureList, setIsGeneratingFeatureList] = useState(false);
const [featureListGenerated, setFeatureListGenerated] = useState(false);
const [featureListError, setFeatureListError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Recursively scan directory
const scanDirectory = useCallback(
@@ -647,6 +650,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
} as any);
}
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
setFeatureListGenerated(true);
} catch (error) {
logger.error('Failed to generate feature list:', error);
@@ -656,7 +664,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
} finally {
setIsGeneratingFeatureList(false);
}
}, [currentProject, projectAnalysis]);
}, [currentProject, projectAnalysis, queryClient]);
// Toggle folder expansion
const toggleFolder = (path: string) => {

View File

@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
@@ -35,6 +36,7 @@ import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { Spinner } from '@/components/ui/spinner';
import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -48,19 +50,21 @@ import {
CompletedFeaturesModal,
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
DependencyLinkDialog,
EditFeatureDialog,
FollowUpDialog,
PlanApprovalDialog,
PullResolveConflictsDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-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 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 {
useBoardFeatures,
@@ -79,6 +83,10 @@ import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
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
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -108,9 +116,37 @@ export function BoardView() {
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
} = useAppStore();
// Subscribe to pipelineConfigByProject to trigger re-renders when it changes
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
} = useAppStore(
useShallow((state) => ({
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
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
@@ -149,7 +185,7 @@ export function BoardView() {
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false);
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string;
branch: string;
@@ -326,10 +362,22 @@ export function BoardView() {
fetchBranches();
}, [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) => {
// First, check if pointer is within a column
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) =>
COLUMNS.some((col) => col.id === collision.id)
);
@@ -339,7 +387,7 @@ export function BoardView() {
return columnCollisions;
}
// Otherwise, use rectangle intersection for cards
// Priority 3: Fallback to rectangle intersection
return rectIntersection(args);
}, []);
@@ -797,10 +845,15 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts
const handleResolveConflicts = useCallback(
async (worktree: WorktreeInfo) => {
const remoteBranch = `origin/${worktree.branch}`;
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
setSelectedWorktreeForAction(worktree);
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.`;
// Create the feature
@@ -840,6 +893,48 @@ export function BoardView() {
[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
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
@@ -934,7 +1029,13 @@ export function BoardView() {
});
// Use drag and drop hook
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({
const {
activeFeature,
handleDragStart,
handleDragEnd,
pendingDependencyLink,
clearPendingDependencyLink,
} = useBoardDragDrop({
features: hookFeatures,
currentProject,
runningAutoTasks,
@@ -942,6 +1043,50 @@ export function BoardView() {
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
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
features: hookFeatures,
@@ -953,9 +1098,7 @@ export function BoardView() {
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
// pipelineConfig is now from usePipelineConfig React Query hook at the top
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
@@ -1174,133 +1317,148 @@ export function BoardView() {
onViewModeChange={setViewMode}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onMerge={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowMergeWorktreeDialog(true);
}}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
{/* 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 */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel
refreshTrigger={worktreeRefreshKey}
projectPath={currentProject.path}
onCreateWorktree={() => setShowCreateWorktreeDialog(true)}
onDeleteWorktree={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowDeleteWorktreeDialog(true);
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
onCommit={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCommitWorktreeDialog(true);
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
onCreatePR={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreatePRDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
onCreateBranch={(worktree) => {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchDeletedDuringMerge={(branchName) => {
// 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}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
features={hookFeatures.map((f) => ({
id: f.id,
branchName: f.branchName,
}))}
/>
)}
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
/>
)}
</div>
</DndContext>
{/* Selection Action Bar */}
{isSelectionMode && (
@@ -1394,6 +1552,15 @@ export function BoardView() {
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 */}
<EditFeatureDialog
feature={editingFeature}
@@ -1441,6 +1608,11 @@ export function BoardView() {
if (!result.success) {
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);
}}
/>
@@ -1560,33 +1732,12 @@ export function BoardView() {
}}
/>
{/* Merge Worktree Dialog */}
<MergeWorktreeDialog
open={showMergeWorktreeDialog}
onOpenChange={setShowMergeWorktreeDialog}
projectPath={currentProject.path}
{/* Pull & Resolve Conflicts Dialog */}
<PullResolveConflictsDialog
open={showPullResolveConflictsDialog}
onOpenChange={setShowPullResolveConflictsDialog}
worktree={selectedWorktreeForAction}
affectedFeatureCount={
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);
}}
onConfirm={handleConfirmResolveConflicts}
/>
{/* Commit Worktree Dialog */}

View File

@@ -1,5 +1,4 @@
// @ts-nocheck
import { useEffect, useState, useMemo } from 'react';
import { memo, useEffect, useState, useMemo } from 'react';
import { Feature, ThinkingLevel, ParsedTask } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
@@ -16,6 +15,7 @@ import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
import { useFeature, useAgentOutput } from '@/hooks/queries';
/**
* Formats thinking level for compact display
@@ -50,30 +50,62 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
interface AgentInfoPanelProps {
feature: Feature;
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
}
export function AgentInfoPanel({
export const AgentInfoPanel = memo(function AgentInfoPanel({
feature,
projectPath,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
const [taskStatusMap, setTaskStatusMap] = useState<
Map<string, 'pending' | 'in_progress' | 'completed'>
>(new Map());
// Fresh planSpec data fetched from API (store data is stale for task progress)
const [freshPlanSpec, setFreshPlanSpec] = useState<{
tasks?: ParsedTask[];
tasksCompleted?: number;
currentTaskId?: string;
} | null>(null);
// Determine if we should poll for updates
const shouldPoll = isCurrentAutoTask || feature.status === 'in_progress';
const shouldFetchData = feature.status !== 'backlog';
// 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
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
@@ -125,73 +157,6 @@ export function AgentInfoPanel({
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
// 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
@@ -440,4 +405,4 @@ export function AgentInfoPanel({
onOpenChange={setIsSummaryDialogOpen}
/>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
@@ -32,7 +33,7 @@ interface CardActionsProps {
onApprovePlan?: () => void;
}
export function CardActions({
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
hasContext,
@@ -344,4 +345,4 @@ export function CardActions({
)}
</div>
);
}
});

View File

@@ -1,10 +1,11 @@
// @ts-nocheck
import { useEffect, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useShallow } from 'zustand/react/shallow';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -18,7 +19,7 @@ interface CardBadgesProps {
* CardBadges - Shows error badges below the card header
* 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) {
return null;
}
@@ -46,14 +47,19 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipProvider>
</div>
);
}
});
interface PriorityBadgesProps {
feature: Feature;
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore(
useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking,
features: state.features,
}))
);
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
@@ -223,4 +229,4 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
</div>
);
}
});

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { memo } from 'react';
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react';
@@ -7,7 +8,10 @@ interface CardContentSectionsProps {
useWorktrees: boolean;
}
export function CardContentSections({ feature, useWorktrees }: CardContentSectionsProps) {
export const CardContentSections = memo(function CardContentSections({
feature,
useWorktrees,
}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
@@ -48,4 +52,4 @@ export function CardContentSections({ feature, useWorktrees }: CardContentSectio
})()}
</>
);
}
});

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import { useState } from 'react';
import { memo, useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -37,7 +37,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
}
export function CardHeaderSection({
export const CardHeaderSection = memo(function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
@@ -378,4 +378,4 @@ export function CardHeaderSection({
/>
</CardHeader>
);
}
});

View File

@@ -1,10 +1,11 @@
// @ts-nocheck
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Feature, useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
@@ -61,6 +62,7 @@ interface KanbanCardProps {
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
reduceEffects?: boolean;
// Selection mode props
isSelectionMode?: boolean;
isSelected?: boolean;
@@ -94,12 +96,18 @@ export const KanbanCard = memo(function KanbanCard({
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
reduceEffects = false,
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const { useWorktrees, currentProject } = useAppStore(
useShallow((state) => ({
useWorktrees: state.useWorktrees,
currentProject: state.currentProject,
}))
);
const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => {
@@ -115,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
(feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
feature.status.startsWith('pipeline_') ||
(feature.status === 'in_progress' && !isCurrentAutoTask));
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
} = useDraggable({
id: feature.id,
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 = {
opacity: isDragging ? 0.5 : undefined,
};
@@ -133,16 +168,21 @@ export const KanbanCard = memo(function KanbanCard({
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
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 hasError = feature.error && !isCurrentAutoTask;
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',
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]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
@@ -215,6 +255,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import type { CSSProperties, ReactNode, Ref, UIEvent } from 'react';
interface KanbanColumnProps {
id: string;
@@ -17,6 +17,11 @@ interface KanbanColumnProps {
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
contentRef?: Ref<HTMLDivElement>;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
contentClassName?: string;
contentStyle?: CSSProperties;
disableItemSpacing?: boolean;
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -31,6 +36,11 @@ export const KanbanColumn = memo(function KanbanColumn({
showBorder = true,
hideScrollbar = false,
width,
contentRef,
onScroll,
contentClassName,
contentStyle,
disableItemSpacing = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -78,14 +88,19 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */}
<div
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 &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth',
// Add padding at bottom if there's a footer action
footerAction && 'pb-14'
footerAction && 'pb-14',
contentClassName
)}
ref={contentRef}
onScroll={onScroll}
style={contentStyle}
>
{children}
</div>

View File

@@ -23,7 +23,6 @@ interface ColumnDef {
/**
* 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[] = [
{
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
minWidth: 'min-w-0',
align: 'left',
},
{
id: 'priority',
label: '',
sortable: true,
width: 'w-18',
minWidth: 'min-w-[16px]',
align: 'center',
},
];
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',
column.width,
column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
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',
column.width,
column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className

View File

@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 gap-2',
'flex items-center pl-3 pr-0 py-3 gap-0',
getColumnWidth('title'),
getColumnAlign('title')
)}
@@ -315,6 +315,42 @@ export const ListRow = memo(function ListRow({
</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 */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<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 { useAppStore } from '@/store/app-store';
import { extractSummary } from '@/lib/log-parser';
import { useAgentOutput } from '@/hooks/queries';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
@@ -45,10 +46,30 @@ export function AgentOutputModal({
branchName,
}: AgentOutputModalProps) {
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 [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
const summary = useMemo(() => extractSummary(output), [output]);
@@ -57,7 +78,6 @@ export function AgentOutputModal({
const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -67,55 +87,6 @@ export function AgentOutputModal({
}
}, [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
useEffect(() => {
if (!open) return;
@@ -274,8 +245,8 @@ export function AgentOutputModal({
}
if (newContent) {
// Only update local state - server is the single source of truth for file writes
setOutput((prev) => prev + newContent);
// Append new content from WebSocket to streamed content
setStreamedContent((prev) => prev + newContent);
}
});
@@ -426,16 +397,16 @@ export function AgentOutputModal({
{!isBacklogPlan && (
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
projectPath={resolvedProjectPath}
className="shrink-0 mx-3 my-2"
/>
)}
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
{resolvedProjectPath ? (
<GitDiffPanel
projectPath={projectPath}
projectPath={resolvedProjectPath}
featureId={branchName || featureId}
compact={false}
useWorktrees={useWorktrees}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -17,6 +17,7 @@ import { GitPullRequest, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface WorktreeInfo {
path: string;
@@ -54,12 +55,21 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
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
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
const resetState = useCallback(() => {
setTitle('');
@@ -72,44 +82,13 @@ export function CreatePRDialog({
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
setBranches([]);
}, [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
useEffect(() => {
// Reset all state on both open and close
resetState();
if (open) {
// Fetch fresh branches when dialog opens
fetchBranches();
}
}, [open, worktree?.path, resetState, fetchBranches]);
}, [open, worktree?.path, resetState]);
const handleCreate = async () => {
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 { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-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';

View File

@@ -8,58 +8,81 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
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 { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
export type { MergeConflictInfo } from '../worktree-panel/types';
interface MergeWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
worktree: WorktreeInfo | null;
onMerged: (mergedWorktree: WorktreeInfo) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
type DialogStep = 'confirm' | 'verify';
export function MergeWorktreeDialog({
open,
onOpenChange,
projectPath,
worktree,
onMerged,
affectedFeatureCount = 0,
onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<DialogStep>('confirm');
const [confirmText, setConfirmText] = useState('');
const [targetBranch, setTargetBranch] = useState('main');
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
useEffect(() => {
if (open) {
setIsLoading(false);
setStep('confirm');
setConfirmText('');
setTargetBranch('main');
setDeleteWorktreeAndBranch(false);
setMergeConflict(null);
}
}, [open]);
const handleProceedToVerify = () => {
setStep('verify');
};
const handleMerge = async () => {
if (!worktree) return;
@@ -71,96 +94,151 @@ export function MergeWorktreeDialog({
return;
}
// Pass branchName and worktreePath directly to the API
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path);
// Pass branchName, worktreePath, targetBranch, and options to the API
const result = await api.worktree.mergeFeature(
projectPath,
worktree.branch,
worktree.path,
targetBranch,
{ deleteWorktreeAndBranch }
);
if (result.success) {
toast.success('Branch merged to main', {
description: `Branch "${worktree.branch}" has been merged and cleaned up`,
});
onMerged(worktree);
const description = deleteWorktreeAndBranch
? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
: `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
toast.success(`Branch merged to ${targetBranch}`, { description });
onMerged(worktree, deleteWorktreeAndBranch);
onOpenChange(false);
} else {
toast.error('Failed to merge branch', {
description: result.error,
});
// 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 {
toast.error('Failed to merge branch', {
description: result.error,
});
}
}
} catch (err) {
toast.error('Failed to merge branch', {
description: err instanceof Error ? err.message : 'Unknown error',
});
const errorMessage = 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 {
setIsLoading(false);
}
};
const handleCreateConflictResolutionFeature = () => {
if (mergeConflict && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature(mergeConflict);
onOpenChange(false);
}
};
if (!worktree) return null;
const confirmationWord = 'merge';
const isConfirmValid = confirmText.toLowerCase() === confirmationWord;
// First step: Show what will happen and ask for confirmation
if (step === 'confirm') {
// Show conflict resolution UI if there are merge conflicts
if (mergeConflict) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" />
Merge to Main
<AlertTriangle className="w-5 h-5 text-orange-500" />
Merge Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<div className="space-y-4">
<span className="block">
Merge branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into
main?
There are conflicts when merging{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.sourceBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.targetBranch}
</code>
.
</span>
<div className="text-sm text-muted-foreground mt-2">
This will:
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Merge the branch into the main branch</li>
<li>Remove the worktree directory</li>
<li>Delete the branch</li>
</ul>
<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 merge could not be completed automatically. You can create a feature task to
resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch.
</span>
</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 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>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
<Button variant="ghost" onClick={() => setMergeConflict(null)}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleProceedToVerify}
disabled={worktree.hasChanges}
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Continue
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
</Button>
</DialogFooter>
</DialogContent>
@@ -168,52 +246,86 @@ export function MergeWorktreeDialog({
);
}
// Second step: Type confirmation
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Confirm Merge
<GitMerge className="w-5 h-5 text-green-600" />
Merge Branch
</DialogTitle>
<DialogDescription asChild>
<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">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-600 dark:text-orange-400 text-sm">
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>
</div>
<span className="block">
Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
into:
</span>
<div className="space-y-2">
<Label htmlFor="confirm-merge" className="text-sm text-foreground">
Type <span className="font-bold text-foreground">{confirmationWord}</span> to
confirm:
<Label htmlFor="target-branch" className="text-sm text-foreground">
Target Branch
</Label>
<Input
id="confirm-merge"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={confirmationWord}
disabled={isLoading}
className="font-mono"
autoComplete="off"
/>
{loadingBranches ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
Loading branches...
</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>
</DialogDescription>
</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>
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}>
Back
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
onClick={handleMerge}
disabled={isLoading || !isConfirmValid}
disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
@@ -223,8 +335,8 @@ export function MergeWorktreeDialog({
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Merge to Main
<GitMerge className="w-4 h-4 mr-2" />
Merge
</>
)}
</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 { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
@@ -91,9 +92,14 @@ export function useBoardActions({
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getAutoModeState,
} = useAppStore();
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
// This ensures the worktree exists before the feature starts execution
@@ -480,10 +486,22 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
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', {
description: `You can only have ${autoMode.maxConcurrency} task${
autoMode.maxConcurrency > 1 ? 's' : ''
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
@@ -547,34 +565,17 @@ export function useBoardActions({
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
]
);
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
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();
}
verifyFeatureMutation.mutate(feature.id);
},
[currentProject, loadFeatures]
[currentProject, verifyFeatureMutation]
);
const handleResumeFeature = useCallback(
@@ -584,40 +585,9 @@ export function useBoardActions({
logger.error('No current project');
return;
}
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();
}
resumeFeatureMutation.mutate({ featureId: feature.id, useWorktrees });
},
[currentProject, loadFeatures, useWorktrees]
[currentProject, resumeFeatureMutation, useWorktrees]
);
const handleManualVerify = useCallback(

View File

@@ -1,7 +1,11 @@
// @ts-nocheck
import { useMemo, useCallback } from 'react';
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'];
@@ -32,6 +36,8 @@ export function useBoardColumnFeatures({
verified: [],
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)
const normalizedQuery = searchQuery.toLowerCase().trim();
@@ -55,7 +61,7 @@ export function useBoardColumnFeatures({
filteredFeatures.forEach((f) => {
// 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
// Features without branchName are considered unassigned (show only on primary worktree)
@@ -168,7 +174,6 @@ export function useBoardColumnFeatures({
const { orderedFeatures } = resolveDependencies(map.backlog);
// Get all features to check blocking dependencies against
const allFeatures = features;
const enableDependencyBlocking = useAppStore.getState().enableDependencyBlocking;
// Sort blocked features to the end of the backlog
@@ -178,7 +183,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
if (getBlockingDependenciesFromMap(f, featureMap).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);

View File

@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop');
export interface PendingDependencyLink {
draggedFeature: Feature;
targetFeature: Feature;
}
interface UseBoardDragDropProps {
features: Feature[];
currentProject: { path: string; id: string } | null;
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
handleStartImplementation,
}: UseBoardDragDropProps) {
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
// at execution time based on feature.branchName
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
[features]
);
// Clear pending dependency link
const clearPendingDependencyLink = useCallback(() => {
setPendingDependencyLink(null);
}, []);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
@@ -57,6 +70,85 @@ export function useBoardDragDrop({
// Check if this is a running task (non-skipTests, TDD)
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
// - Backlog items can always be dragged
// - 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 {
activeFeature,
handleDragStart,
handleDragEnd,
pendingDependencyLink,
clearPendingDependencyLink,
};
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects');
@@ -65,37 +64,8 @@ export function useBoardEffects({
};
}, [specCreatingForProject, setSpecCreatingForProject]);
// Sync running tasks from electron backend on mount
useEffect(() => {
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]);
// Note: Running tasks sync is now handled by useAutoMode hook in BoardView
// which correctly handles worktree/branch scoping.
// Check which features have context files
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 { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { useFeatures } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardFeatures');
@@ -11,105 +21,15 @@ interface UseBoardFeaturesProps {
}
export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { features, setFeatures } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
// Track previous project path to detect project switches
const prevProjectPathRef = useRef<string | null>(null);
const isInitialLoadRef = useRef(true);
const isSwitchingProjectRef = useRef(false);
// Load features using features API
// 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]);
// Use React Query for features
const {
data: features = [],
isLoading,
refetch: loadFeatures,
} = useFeatures(currentProject?.path);
// Load persisted categories from file
const loadCategories = useCallback(async () => {
@@ -125,15 +45,12 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
setPersistedCategories(parsed);
}
} else {
// File doesn't exist, ensure categories are cleared
setPersistedCategories([]);
}
} catch (error) {
logger.error('Failed to load categories:', error);
// If file doesn't exist, ensure categories are cleared
} catch {
setPersistedCategories([]);
}
}, [currentProject]);
}, [currentProject, loadFeatures]);
// Save a new category to the persisted categories file
const saveCategory = useCallback(
@@ -142,22 +59,17 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
try {
const api = getElectronAPI();
// Read existing categories
let categories: string[] = [...persistedCategories];
// Add new category if it doesn't exist
if (!categories.includes(category)) {
categories.push(category);
categories.sort(); // Keep sorted
categories.sort();
// Write back to file
await api.writeFile(
`${currentProject.path}/.automaker/categories.json`,
JSON.stringify(categories, null, 2)
);
// Update state
setPersistedCategories(categories);
}
} catch (error) {
@@ -167,29 +79,8 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
[currentProject, persistedCategories]
);
// Subscribe to spec regeneration complete events to refresh kanban board
useEffect(() => {
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
// Subscribe to auto mode events for notifications (ding sound, toasts)
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
@@ -229,28 +120,15 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const audio = new Audio('/sounds/ding.mp3');
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') {
// Reload features when an error occurs (feature moved to waiting_approval)
logger.info('Feature error, reloading features...', event.error);
// Remove from running tasks so it moves to the correct column
// Remove from running tasks
if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId);
const eventBranchName =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
loadFeatures();
// Check for authentication errors and show a more helpful message
// Show error toast
const isAuthError =
event.errorType === 'authentication' ||
(event.error &&
@@ -272,22 +150,46 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
});
return unsubscribe;
}, [loadFeatures, currentProject]);
}, [currentProject]);
// Check for interrupted features on mount
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (!currentProject) return;
// 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(() => {
loadCategories();
}, [loadCategories]);
// Clear categories when project changes
useEffect(() => {
setPersistedCategories([]);
}, [currentProject?.path]);
return {
features,
isLoading,
persistedCategories,
loadFeatures,
loadFeatures: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject?.path ?? ''),
});
},
loadCategories,
saveCategory,
};

View File

@@ -1,8 +1,10 @@
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
const logger = createLogger('BoardPersistence');
@@ -12,6 +14,7 @@ interface UseBoardPersistenceProps {
export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps) {
const { updateFeature } = useAppStore();
const queryClient = useQueryClient();
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
@@ -45,7 +48,21 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
feature: 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) {
logger.error('API features.update failed', result);
}
@@ -53,7 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
logger.error('Failed to persist feature update:', error);
}
},
[currentProject, updateFeature]
[currentProject, updateFeature, queryClient]
);
// Persist feature creation to API
@@ -71,12 +88,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
const result = await api.features.create(currentProject.path, feature);
if (result.success && 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) {
logger.error('Failed to persist feature creation:', error);
}
},
[currentProject, updateFeature]
[currentProject, updateFeature, queryClient]
);
// Persist feature deletion to API
@@ -92,11 +113,15 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
}
await api.features.delete(currentProject.path, featureId);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} catch (error) {
logger.error('Failed to persist feature deletion:', error);
}
},
[currentProject]
[currentProject, queryClient]
);
return {

View File

@@ -1,5 +1,13 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import {
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 { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
@@ -10,10 +18,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
onDragStart: (event: any) => void;
onDragEnd: (event: any) => void;
activeFeature: Feature | null;
getColumnFeatures: (columnId: ColumnId) => Feature[];
backgroundImageStyle: React.CSSProperties;
@@ -64,11 +68,200 @@ interface KanbanBoardProps {
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({
sensors,
collisionDetectionStrategy,
onDragStart,
onDragEnd,
activeFeature,
getColumnFeatures,
backgroundImageStyle,
@@ -109,7 +302,7 @@ export function KanbanBoard({
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
// Get the keyboard shortcut for adding features
const { keyboardShortcuts } = useAppStore();
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Use responsive column widths based on window size
@@ -125,80 +318,125 @@ export function KanbanBoard({
)}
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="h-full py-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<div className="h-full py-1" style={containerStyle}>
{columns.map((column) => {
const columnFeatures = getColumnFeatures(column.id as ColumnId);
return (
<VirtualizedList
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}
title={column.title}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
contentRef={contentRef}
onScroll={shouldVirtualize ? onScroll : undefined}
disableItemSpacing={shouldVirtualize}
contentClassName="perf-contain"
headerAction={
column.id === 'verified' ? (
<div className="flex items-center gap-1">
{columnFeatures.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onArchiveAllVerified}
data-testid="archive-all-verified-button"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3 h-3 mr-1" />
Complete All
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
)}
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
>
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 relative"
onClick={onShowCompletedModal}
title={`Completed Features (${completedCount})`}
data-testid="completed-features-button"
>
<Archive className="w-3.5 h-3.5 text-muted-foreground" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[8px] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center">
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
</div>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="default"
size="sm"
className="h-6 w-6 p-0"
onClick={onAddFeature}
title="Add Feature"
data-testid="add-feature-button"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="selection-mode-button"
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'backlog' ? (
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
@@ -210,178 +448,216 @@ export function KanbanBoard({
</>
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : column.isPipelineStep ? (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenPipelineSettings}
title="Edit Pipeline Step"
data-testid="edit-pipeline-step-button"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
) : undefined
}
footerAction={
column.id === 'backlog' ? (
<Button
variant="default"
size="sm"
className="w-full h-9 text-sm"
onClick={onAddFeature}
data-testid="add-feature-floating-button"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
) : undefined
}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
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={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
{(() => {
const reduceEffects = shouldVirtualize;
const effectiveCardOpacity = reduceEffects
? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT)
: backgroundSettings.cardOpacity;
const effectiveGlassmorphism =
backgroundSettings.cardGlassmorphism && !reduceEffects;
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={effectiveCardOpacity}
glassmorphism={effectiveGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{shouldVirtualize ? (
<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;
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
key={feature.id}
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)}
/>
);
})
)}
</SortableContext>
);
})()}
</KanbanColumn>
)}
</VirtualizedList>
);
})}
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</div>
);
}

View File

@@ -27,11 +27,12 @@ import {
Copy,
Eye,
ScrollText,
Sparkles,
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
Undo2,
Zap,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps {
isSelected: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
isPulling: boolean;
isPushing: boolean;
isStartingDevServer: boolean;
@@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps {
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps {
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({
isSelected,
aheadCount,
behindCount,
hasRemoteBranch,
isPulling,
isPushing,
isStartingDevServer,
@@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({
onOpenChange,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
@@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
@@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
onMerge,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPush(worktree)}
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
onClick={() => {
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')}
>
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'}
{!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">
{aheadCount} ahead
</span>
@@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</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 />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
@@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({
)}
{!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
onClick={() => onDeleteWorktree(worktree)}
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 { cn } from '@/lib/utils';
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 { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -28,6 +29,7 @@ interface WorktreeTabProps {
isStartingDevServer: boolean;
aheadCount: number;
behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
@@ -39,6 +41,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -79,6 +82,7 @@ export function WorktreeTab({
isStartingDevServer,
aheadCount,
behindCount,
hasRemoteBranch,
gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree,
@@ -89,6 +93,7 @@ export function WorktreeTab({
onCreateBranch,
onPull,
onPush,
onPushNewBranch,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
@@ -108,6 +113,16 @@ export function WorktreeTab({
onToggleAutoMode,
hasInitScript,
}: 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;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
@@ -194,7 +209,13 @@ export function WorktreeTab({
}
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 ? (
<>
<Button
@@ -366,6 +387,7 @@ export function WorktreeTab({
isSelected={isSelected}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -376,6 +398,7 @@ export function WorktreeTab({
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}

View File

@@ -1,65 +1,46 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useMemo, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
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';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
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() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
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);
}
}, []);
const queryClient = useQueryClient();
const { data: editors = [], isLoading } = useAvailableEditorsQuery();
/**
* 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
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
mutationFn: async () => {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
if (!result.success) {
throw new Error(result.error || 'Failed to refresh editors');
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
return result.result?.editors ?? [];
},
onSuccess: (newEditors) => {
// Update the cache with new editors
queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
},
});
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
const refresh = useCallback(() => {
refreshMutate();
}, [refreshMutate]);
return {
editors,

View File

@@ -1,66 +1,46 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import type { BranchInfo, GitRepoStatus } from '../types';
const logger = createLogger('Branches');
import { useWorktreeBranches } from '@/hooks/queries';
import type { GitRepoStatus } from '../types';
/**
* 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() {
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [branchFilter, setBranchFilter] = useState('');
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
isGitRepo: true,
hasCommits: true,
});
/** Helper to reset branch state to initial values */
const resetBranchState = useCallback(() => {
setBranches([]);
setAheadCount(0);
setBehindCount(0);
}, []);
const {
data: branchData,
isLoading: isLoadingBranches,
refetch,
} = 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(
async (worktreePath: string) => {
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
logger.warn('List branches API not available');
return;
}
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);
(worktreePath: string) => {
if (worktreePath === currentWorktreePath) {
// Same path - just refetch to get latest data
refetch();
} else {
// Different path - update the tracked path (triggers new query)
setCurrentWorktreePath(worktreePath);
}
},
[resetBranchState]
[currentWorktreePath, refetch]
);
const resetBranchFilter = useCallback(() => {
@@ -76,6 +56,7 @@ export function useBranches() {
filteredBranches,
aheadCount,
behindCount,
hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,

View File

@@ -17,6 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
// Match by branchName only (worktreePath is no longer stored)
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;
}

View File

@@ -3,128 +3,53 @@ import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import {
useSwitchBranch,
usePullWorktree,
usePushWorktree,
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
// Error codes that need special user-friendly handling
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) {
export function useWorktreeActions() {
const navigate = useNavigate();
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = 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(
async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return;
setIsSwitching(true);
try {
const api = getElectronAPI();
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);
}
if (switchBranchMutation.isPending || branchName === worktree.branch) return;
switchBranchMutation.mutate({
worktreePath: worktree.path,
branchName,
});
},
[isSwitching, fetchWorktrees]
[switchBranchMutation]
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
if (isPulling) return;
setIsPulling(true);
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);
}
if (pullMutation.isPending) return;
pullMutation.mutate(worktree.path);
},
[isPulling, fetchWorktrees]
[pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
if (isPushing) return;
setIsPushing(true);
try {
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);
}
if (pushMutation.isPending) return;
pushMutation.mutate({
worktreePath: worktree.path,
});
},
[isPushing, fetchBranches, fetchWorktrees]
[pushMutation]
);
const handleOpenInIntegratedTerminal = useCallback(
@@ -140,23 +65,15 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[navigate]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
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 handleOpenInEditor = useCallback(
async (worktree: WorktreeInfo, editorCommand?: string) => {
openInEditorMutation.mutate({
worktreePath: worktree.path,
editorCommand,
});
},
[openInEditorMutation]
);
const handleOpenInExternalTerminal = useCallback(
async (worktree: WorktreeInfo, terminalId?: string) => {
@@ -180,9 +97,9 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
);
return {
isPulling,
isPushing,
isSwitching,
isPulling: pullMutation.isPending,
isPushing: pushMutation.isPending,
isSwitching: switchBranchMutation.isPending,
isActivating,
setIsActivating,
handleSwitchBranch,

View File

@@ -1,12 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useEffect, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
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 type { WorktreeInfo } from '../types';
const logger = createLogger('Worktrees');
interface UseWorktreesOptions {
projectPath: string;
refreshTrigger?: number;
@@ -18,62 +17,46 @@ export function useWorktrees({
refreshTrigger = 0,
onRemovedWorktrees,
}: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false);
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const queryClient = useQueryClient();
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(
async (options?: { silent?: boolean }) => {
if (!projectPath) return;
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]
);
// Use the React Query hook
const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
// Sync worktrees to Zustand store when they change
useEffect(() => {
fetchWorktrees();
}, [fetchWorktrees]);
if (worktrees.length > 0) {
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(() => {
if (refreshTrigger > 0) {
fetchWorktrees().then((removedWorktrees) => {
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
// Invalidate and refetch to get fresh data including any removed worktrees
queryClient.invalidateQueries({
queryKey: queryKeys.worktrees.all(projectPath),
});
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
}, [refreshTrigger, projectPath, queryClient]);
// 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)
@@ -111,6 +94,14 @@ export function useWorktrees({
[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 selectedWorktree = 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 {
projectPath: string;
onCreateWorktree: () => void;
@@ -70,7 +76,9 @@ export interface WorktreePanelProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => 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;
runningFeatureIds?: string[];
features?: FeatureInfo[];

View File

@@ -6,6 +6,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript } from '@/hooks/queries';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -22,9 +23,10 @@ import {
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
export function WorktreePanel({
projectPath,
@@ -35,7 +37,8 @@ export function WorktreePanel({
onCreateBranch,
onAddressPRComments,
onResolveConflicts,
onMerge,
onCreateMergeConflictResolutionFeature,
onBranchDeletedDuringMerge,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
@@ -66,6 +69,7 @@ export function WorktreePanel({
filteredBranches,
aheadCount,
behindCount,
hasRemoteBranch,
isLoadingBranches,
branchFilter,
setBranchFilter,
@@ -85,10 +89,7 @@ export function WorktreePanel({
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
});
} = useWorktreeActions();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
@@ -156,8 +157,9 @@ export function WorktreePanel({
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
// Check if init script exists for the project using React Query
const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// View changes dialog state
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
@@ -171,24 +173,13 @@ export function WorktreePanel({
const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
// Push to remote dialog state
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Merge branch dialog state
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile();
@@ -300,6 +291,54 @@ export function WorktreePanel({
// 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 nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -345,6 +384,7 @@ export function WorktreePanel({
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
@@ -355,6 +395,7 @@ export function WorktreePanel({
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -364,7 +405,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -435,6 +476,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer}
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>
);
}
@@ -468,6 +527,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
@@ -478,6 +538,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -487,7 +548,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -532,6 +593,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree}
@@ -542,6 +604,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -551,7 +614,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
@@ -622,6 +685,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer}
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>
);
}

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 { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -22,6 +24,10 @@ import {
} from '@/components/ui/dropdown-menu';
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() {
const {
chatSessions,
@@ -34,29 +40,117 @@ export function ChatHistory() {
unarchiveChatSession,
deleteChatSession,
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 [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) {
return null;
}
const normalizedQuery = searchQuery.trim().toLowerCase();
const currentProjectId = currentProject?.id;
// 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
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
const filteredSessions = useMemo(() => {
return projectSessions.filter((session) => {
const matchesSearch = session.title.toLowerCase().includes(normalizedQuery);
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
return matchesSearch && matchesArchivedStatus;
});
}, [projectSessions, normalizedQuery, showArchived]);
// Sort by most recently updated
const sortedSessions = filteredSessions.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
const sortedSessions = useMemo(() => {
return [...filteredSessions].sort(
(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 = () => {
createChatSession();
@@ -151,7 +245,11 @@ export function ChatHistory() {
</div>
{/* 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 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery ? (
@@ -163,60 +261,75 @@ export function ChatHistory() {
)}
</div>
) : (
<div className="p-2">
{sortedSessions.map((session) => (
<div
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div
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
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
)}
style={{ height: CHAT_SESSION_ROW_HEIGHT_PX }}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
<p className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) => handleUnarchiveSession(session.id, e)}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => handleArchiveSession(session.id, e)}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleUnarchiveSession(session.id, e)}
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteSession(session.id, e)}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))}
))}
</div>
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
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 { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { toast } from 'sonner';
import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
@@ -36,6 +38,7 @@ export function GitHubIssuesView() {
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
const queryClient = useQueryClient();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
@@ -153,6 +156,10 @@ export function GitHubIssuesView() {
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(`Created task: ${issue.title}`);
} else {
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');
}
},
[currentProject?.path, currentBranch]
[currentProject?.path, currentBranch, queryClient]
);
if (loading) {

View File

@@ -1,79 +1,29 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubIssue } from '@/lib/electron';
/**
* GitHub Issues Hook
*
* React Query-based hook for fetching GitHub issues.
*/
const logger = createLogger('GitHubIssues');
import { useAppStore } from '@/store/app-store';
import { useGitHubIssues as useGitHubIssuesQuery } from '@/hooks/queries';
export function useGithubIssues() {
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 () => {
if (!currentProject?.path) {
if (isMountedRef.current) {
setError('No project selected');
setLoading(false);
}
return;
}
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]);
const {
data,
isLoading: loading,
isFetching: refreshing,
error,
refetch: refresh,
} = useGitHubIssuesQuery(currentProject?.path);
return {
openIssues,
closedIssues,
openIssues: data?.openIssues ?? [],
closedIssues: data?.closedIssues ?? [],
loading,
refreshing,
error,
error: error instanceof Error ? error.message : error ? String(error) : null,
refresh,
};
}

View File

@@ -1,9 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI, GitHubComment } from '@/lib/electron';
const logger = createLogger('IssueComments');
import { useMemo, useCallback } from 'react';
import type { GitHubComment } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useGitHubIssueComments } from '@/hooks/queries';
interface UseIssueCommentsResult {
comments: GitHubComment[];
@@ -18,119 +16,36 @@ interface UseIssueCommentsResult {
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
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(
async (cursor?: string) => {
if (!currentProject?.path || !issueNumber) {
return;
}
// Use React Query infinite query
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, error } =
useGitHubIssueComments(currentProject?.path, issueNumber ?? undefined);
const isLoadingMore = !!cursor;
// Flatten all pages into a single comments array
const comments = useMemo(() => {
return data?.pages.flatMap((page) => page.comments) ?? [];
}, [data?.pages]);
try {
if (isMountedRef.current) {
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]);
// Get total count from the first page
const totalCount = data?.pages[0]?.totalCount ?? 0;
const loadMore = useCallback(() => {
if (hasNextPage && endCursor && !loadingMore) {
fetchComments(endCursor);
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const refresh = useCallback(() => {
setComments([]);
setEndCursor(undefined);
fetchComments();
}, [fetchComments]);
refetch();
}, [refetch]);
return {
comments,
totalCount,
loading,
loadingMore,
hasNextPage,
error,
loading: isLoading,
loadingMore: isFetchingNextPage,
hasNextPage: hasNextPage ?? false,
error: error instanceof Error ? error.message : null,
loadMore,
refresh,
};

View File

@@ -13,6 +13,7 @@ import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations';
const logger = createLogger('IssueValidation');
@@ -46,6 +47,10 @@ export function useIssueValidation({
new Map()
);
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)
const selectedIssueRef = useRef<GitHubIssue | null>(null);
const showValidationDialogRef = useRef(false);
@@ -240,7 +245,7 @@ export function useIssueValidation({
}
// 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}`);
return;
}
@@ -254,11 +259,6 @@ export function useIssueValidation({
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
// Extract model string and thinking level from PhaseModelEntry (handles both old string format and new object format)
const effectiveModelEntry = modelEntry
@@ -276,40 +276,22 @@ export function useIssueValidation({
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
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');
}
// Use mutation to trigger validation (toast is handled by mutation)
validateIssueMutation.mutate({
issue,
model: modelToUse,
thinkingLevel: thinkingLevelToUse,
reasoningEffort: reasoningEffortToUse,
comments,
linkedPRs,
});
},
[
currentProject?.path,
validatingIssues,
cachedValidations,
phaseModels.validationModel,
validateIssueMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -325,10 +307,8 @@ export function useIssueValidation({
// Mark as viewed if not already viewed
if (!cached.viewedAt && currentProject?.path) {
try {
const api = getElectronAPI();
if (api.github?.markValidationViewed) {
await api.github.markValidationViewed(currentProject.path, issue.number);
markViewedMutation.mutate(issue.number, {
onSuccess: () => {
// Update local state
setCachedValidations((prev) => {
const next = new Map(prev);
@@ -341,16 +321,15 @@ export function useIssueValidation({
}
return next;
});
}
} catch (err) {
logger.error('Failed to mark validation as viewed:', err);
}
},
});
}
}
},
[
cachedValidations,
currentProject?.path,
markViewedMutation,
onValidationResultChange,
onShowValidationDialogChange,
]
@@ -361,5 +340,6 @@ export function useIssueValidation({
cachedValidations,
handleValidateIssue,
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 { 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 { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
const logger = createLogger('GitHubPRsView');
import { useGitHubPRs } from '@/hooks/queries';
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 { currentProject } = useAppStore();
const fetchPRs = useCallback(async () => {
if (!currentProject?.path) {
setError('No project selected');
setLoading(false);
return;
}
const {
data,
isLoading: loading,
isFetching: refreshing,
error,
refetch,
} = useGitHubPRs(currentProject?.path);
try {
setError(null);
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 openPRs = data?.openPRs ?? [];
const mergedPRs = data?.mergedPRs ?? [];
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchPRs();
}, [fetchPRs]);
refetch();
}, [refetch]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
@@ -99,7 +76,9 @@ export function GitHubPRsView() {
<GitPullRequest className="h-12 w-12 text-destructive" />
</div>
<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}>
<RefreshCw className="h-4 w-4 mr-2" />
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 gap-2 min-w-0">
{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">
#{selectedPR.number} {selectedPR.title}
@@ -210,7 +189,7 @@ export function GitHubPRsView() {
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
@@ -342,16 +321,16 @@ function PRRow({
onClick={onClick}
>
{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 items-center gap-2">
<span className="text-sm font-medium truncate">{pr.title}</span>
{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
</span>
)}
@@ -402,7 +381,7 @@ function PRRow({
<Button
variant="ghost"
size="sm"
className="flex-shrink-0 opacity-0 group-hover:opacity-100"
className="shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
import { GraphView } from './graph-view';
import {
EditFeatureDialog,
@@ -40,7 +41,20 @@ export function GraphViewPage() {
addFeatureUseSelectedWorktreeBranch,
planUseSelectedWorktreeBranch,
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
useWorktrees({ projectPath: currentProject?.path ?? '' });

View File

@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
import { Trash2 } from 'lucide-react';
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}
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 isDimmed = edgeData?.isDimmed ?? false;
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
const edgeColor = isHighlighted
? '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 (
<>
{/* Invisible wider path for hover detection */}

View File

@@ -18,6 +18,7 @@ import {
Trash2,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
const glassmorphism = data.cardGlassmorphism ?? true;
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
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
const borderColor = data.error
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Get computed border style
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 (
<>
{/* 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 {
ReactFlow,
Background,
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -198,6 +204,17 @@ function GraphCanvasInner({
// Calculate filter results
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
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
@@ -205,6 +222,8 @@ function GraphCanvasInner({
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
renderMode,
enableEdgeAnimations: !isLargeGraph,
});
// Apply layout
@@ -457,6 +476,8 @@ function GraphCanvasInner({
}
}, []);
const shouldRenderVisibleOnly = isLargeGraph;
return (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
@@ -478,6 +499,7 @@ function GraphCanvasInner({
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
onlyRenderVisibleElements={shouldRenderVisibleOnly}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>

View File

@@ -51,7 +51,7 @@ export function GraphView({
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: GraphViewProps) {
const { currentProject } = useAppStore();
const currentProject = useAppStore((state) => state.currentProject);
// Use the same background hook as the board view
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)
*/
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;
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) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.includes(featureId)) {
getDescendants(feature.id, features, visited);
if (!deps || deps.length === 0) continue;
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)
* 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') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
}
// Treat completed (archived) as verified
if (feature.status === 'completed') {
@@ -119,6 +143,7 @@ export function useGraphFilter(
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
const runningTaskIds = new Set(runningAutoTasks);
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
@@ -139,6 +164,7 @@ export function useGraphFilter(
// Find directly matched nodes
const matchedNodeIds = new Set<string>();
const featureMap = new Map(features.map((f) => [f.id, f]));
const dependentsMap = buildDependentsMap(features);
for (const feature of features) {
let matchesSearch = true;
@@ -159,7 +185,7 @@ export function useGraphFilter(
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
@@ -190,7 +216,7 @@ export function useGraphFilter(
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
getDescendants(id, features, highlightedNodeIds);
getDescendants(id, dependentsMap, highlightedNodeIds);
}
// Get edges in the highlighted path

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
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';
export interface TaskNodeData extends Feature {
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
onResumeTask?: () => void;
onSpawnTask?: () => void;
onDeleteTask?: () => void;
renderMode?: GraphRenderMode;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}>;
export interface NodeActionCallbacks {
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
renderMode?: GraphRenderMode;
enableEdgeAnimations?: boolean;
}
/**
@@ -78,14 +83,14 @@ export function useGraphNodes({
filterResult,
actionCallbacks,
backgroundSettings,
renderMode = GRAPH_RENDER_MODE_FULL,
enableEdgeAnimations = true,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
const featureMap = new Map<string, Feature>();
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
@@ -95,8 +100,8 @@ export function useGraphNodes({
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
const isRunning = runningTaskIds.has(feature.id);
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
@@ -121,6 +126,7 @@ export function useGraphNodes({
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
renderMode,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -166,13 +172,14 @@ export function useGraphNodes({
source: depId,
target: feature.id,
type: 'dependency',
animated: isRunning || runningAutoTasks.includes(depId),
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
onDeleteDependency: actionCallbacks?.onDeleteDependency,
renderMode,
},
};
edgeList.push(edge);

View File

@@ -9,7 +9,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { useGenerateIdeationSuggestions } from '@/hooks/mutations';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
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 [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
// React Query mutation
const generateMutation = useGenerateIdeationSuggestions(currentProject?.path ?? '');
const {
getPromptsByCategory,
isLoading: isLoadingPrompts,
@@ -57,7 +60,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
return;
}
if (loadingPromptId || generatingPromptIds.has(prompt.id)) return;
if (loadingPromptId || generateMutation.isPending || generatingPromptIds.has(prompt.id)) return;
setLoadingPromptId(prompt.id);
@@ -69,42 +72,31 @@ export function PromptList({ category, onBack }: PromptListProps) {
toast.info(`Generating ideas for "${prompt.title}"...`);
setMode('dashboard');
try {
const api = getElectronAPI();
const result = await api.ideation?.generateSuggestions(
currentProject.path,
prompt.id,
category
);
if (result?.success && result.suggestions) {
updateJobStatus(jobId, 'ready', result.suggestions);
toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
generateMutation.mutate(
{ promptId: prompt.id, category },
{
onSuccess: (data) => {
updateJobStatus(jobId, 'ready', data.suggestions);
toast.success(`Generated ${data.suggestions.length} ideas for "${prompt.title}"`, {
duration: 10000,
action: {
label: 'View Ideas',
onClick: () => {
setMode('dashboard');
navigate({ to: '/ideation' });
},
},
},
});
} else {
updateJobStatus(
jobId,
'error',
undefined,
result?.error || 'Failed to generate suggestions'
);
toast.error(result?.error || 'Failed to generate suggestions');
});
setLoadingPromptId(null);
},
onError: (error) => {
console.error('Failed to generate suggestions:', error);
updateJobStatus(jobId, 'error', undefined, error.message);
toast.error(error.message);
setLoadingPromptId(null);
},
}
} 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);
}
);
};
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 { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useNavigate } from '@tanstack/react-router';
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
const logger = createLogger('RunningAgentsView');
import { useRunningAgents } from '@/hooks/queries';
import { useStopFeature } from '@/hooks/mutations';
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 { setCurrentProject, projects } = useAppStore();
const navigate = useNavigate();
const fetchRunningAgents = useCallback(async () => {
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);
}
}, []);
const logger = createLogger('RunningAgentsView');
// Initial fetch
useEffect(() => {
fetchRunningAgents();
}, [fetchRunningAgents]);
// Use React Query for running agents with auto-refresh
const { data, isLoading, isFetching, refetch } = useRunningAgents();
// Auto-refresh every 2 seconds
useEffect(() => {
const interval = setInterval(() => {
fetchRunningAgents();
}, 2000);
const runningAgents = data?.agents ?? [];
return () => clearInterval(interval);
}, [fetchRunningAgents]);
// 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]);
// Use mutation for stopping features
const stopFeature = useStopFeature();
const handleRefresh = useCallback(() => {
logger.debug('Manual refresh requested for running agents');
setRefreshing(true);
fetchRunningAgents();
}, [fetchRunningAgents]);
refetch();
}, [refetch]);
const handleStopAgent = useCallback(
async (agent: RunningAgent) => {
try {
const api = getElectronAPI();
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
if (isBacklogPlan && api.backlogPlan) {
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
const api = getElectronAPI();
// Handle backlog plans separately - they use a different API
const isBacklogPlan = agent.featureId.startsWith('backlog-plan:');
if (isBacklogPlan && api.backlogPlan) {
logger.debug('Stopping backlog plan agent', { featureId: agent.featureId });
try {
await api.backlogPlan.stop();
fetchRunningAgents();
return;
} catch (error) {
logger.error('Failed to stop backlog plan', { featureId: agent.featureId, error });
} finally {
refetch();
}
if (api.autoMode) {
logger.debug('Stopping running agent', { featureId: agent.featureId });
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);
return;
}
// Use mutation for regular features
stopFeature.mutate({ featureId: agent.featureId, projectPath: agent.projectPath });
},
[fetchRunningAgents]
[stopFeature, refetch, logger]
);
const handleNavigateToProject = useCallback(
(agent: RunningAgent) => {
// Find the project by path
const project = projects.find((p) => p.path === agent.projectPath);
if (project) {
logger.debug('Navigating to running agent project', {
@@ -144,7 +87,7 @@ export function RunningAgentsView() {
setSelectedAgent(agent);
}, []);
if (loading) {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center">
<Spinner size="xl" />
@@ -169,8 +112,8 @@ export function RunningAgentsView() {
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
{refreshing ? (
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
{isFetching ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
@@ -258,7 +201,12 @@ export function RunningAgentsView() {
>
View Project
</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" />
Stop
</Button>

View File

@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { useClaudeUsage } from '@/hooks/queries';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
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 =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
@@ -81,77 +76,31 @@ function UsageCard({
export function ClaudeUsageSection() {
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;
// 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
const hasUsage = !!claudeUsage;
const lastUpdatedLabel = claudeUsageLastUpdated
? new Date(claudeUsageLastUpdated).toLocaleString()
: null;
const lastUpdatedLabel = useMemo(() => {
return dataUpdatedAt ? new Date(dataUpdatedAt).toLocaleString() : null;
}, [dataUpdatedAt]);
const errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) ||
(error && error.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]);
(errorMessage && errorMessage.includes('Authentication required'));
return (
<div
@@ -173,13 +122,13 @@ export function ClaudeUsageSection() {
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
onClick={() => refetch()}
disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage"
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>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
</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">
<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>
)}
@@ -220,7 +169,7 @@ export function ClaudeUsageSection() {
</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">
{CLAUDE_NO_USAGE_MESSAGE}
</div>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
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() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
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() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -20,10 +21,6 @@ interface CursorCliStatusProps {
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function CursorCliStatusSkeleton() {
return (
<div

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -75,10 +76,6 @@ interface OpencodeCliStatusProps {
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function OpencodeCliStatusSkeleton() {
return (
<div

View File

@@ -1,20 +1,17 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import {
formatCodexPlanType,
formatCodexResetTime,
getCodexWindowLabel,
} from '@/lib/codex-usage-format';
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_SUBTITLE = 'Shows usage limits reported by the Codex CLI.';
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 =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CODEX_FETCH_ERROR = 'Failed to fetch usage';
const CODEX_REFRESH_LABEL = 'Refresh Codex usage';
const PLAN_LABEL = 'Plan';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
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_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-emerald-500';
@@ -40,11 +34,12 @@ const isRateLimitWindow = (
export function CodexUsageSection() {
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;
// Use React Query for data fetching with automatic polling
const { data: codexUsage, isLoading, isFetching, error, refetch } = useCodexUsage(canFetchUsage);
const rateLimits = codexUsage?.rateLimits ?? null;
const primary = rateLimits?.primary ?? null;
const secondary = rateLimits?.secondary ?? null;
@@ -55,46 +50,7 @@ export function CodexUsageSection() {
? new Date(codexUsage.lastUpdated).toLocaleString()
: null;
const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading;
const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS;
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 errorMessage = error instanceof Error ? error.message : error ? String(error) : null;
const getUsageColor = (percentage: number) => {
if (percentage >= WARNING_THRESHOLD) {
@@ -163,13 +119,13 @@ export function CodexUsageSection() {
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
onClick={() => refetch()}
disabled={isFetching}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-codex-usage"
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>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
@@ -183,10 +139,10 @@ export function CodexUsageSection() {
</div>
</div>
)}
{error && (
{errorMessage && (
<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" />
<div className="text-sm text-red-400">{error}</div>
<div className="text-sm text-red-400">{errorMessage}</div>
</div>
)}
{hasMetrics && (
@@ -211,7 +167,7 @@ export function CodexUsageSection() {
</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">
{CODEX_NO_USAGE_MESSAGE}
</div>

View File

@@ -1,103 +1,52 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
import { useState, useCallback, useEffect } from 'react';
import { useCursorPermissionsQuery, type CursorPermissionsData } from '@/hooks/queries';
import { useApplyCursorProfile, useCopyCursorConfig } from '@/hooks/mutations';
const logger = createLogger('CursorPermissions');
import { getHttpApiClient } from '@/lib/http-api-client';
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[] };
}>;
}
// Re-export for backward compatibility
export type PermissionsData = CursorPermissionsData;
/**
* Custom hook for managing Cursor CLI permissions
* Handles loading permissions data, applying profiles, and copying configs
*/
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);
// Load permissions data
const loadPermissions = useCallback(async () => {
setIsLoadingPermissions(true);
try {
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]);
// React Query hooks
const permissionsQuery = useCursorPermissionsQuery(projectPath);
const applyProfileMutation = useApplyCursorProfile(projectPath);
const copyConfigMutation = useCopyCursorConfig();
// Apply a permission profile
const applyProfile = useCallback(
async (profileId: 'strict' | 'development', scope: 'global' | 'project') => {
setIsSavingPermissions(true);
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);
}
(profileId: 'strict' | 'development', scope: 'global' | 'project') => {
applyProfileMutation.mutate({ profileId, scope });
},
[projectPath, loadPermissions]
[applyProfileMutation]
);
// Copy example config to clipboard
const copyConfig = useCallback(async (profileId: 'strict' | 'development') => {
try {
const api = getHttpApiClient();
const result = await api.setup.getCursorExampleConfig(profileId);
const copyConfig = useCallback(
(profileId: 'strict' | 'development') => {
copyConfigMutation.mutate(profileId, {
onSuccess: () => {
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
},
});
},
[copyConfigMutation]
);
if (result.success && result.config) {
await navigator.clipboard.writeText(result.config);
setCopiedConfig(true);
toast.success('Config copied to clipboard');
setTimeout(() => setCopiedConfig(false), 2000);
}
} catch (error) {
toast.error('Failed to copy config');
}
}, []);
// Load permissions (refetch)
const loadPermissions = useCallback(() => {
permissionsQuery.refetch();
}, [permissionsQuery]);
return {
permissions,
isLoadingPermissions,
isSavingPermissions,
permissions: permissionsQuery.data ?? null,
isLoadingPermissions: permissionsQuery.isLoading,
isSavingPermissions: applyProfileMutation.isPending,
copiedConfig,
loadPermissions,
applyProfile,

View File

@@ -1,9 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
const logger = createLogger('CursorStatus');
import { getHttpApiClient } from '@/lib/http-api-client';
import { useEffect, useMemo, useCallback } from 'react';
import { useCursorCliStatus } from '@/hooks/queries';
import { useSetupStore } from '@/store/setup-store';
export interface CursorStatus {
@@ -15,52 +11,42 @@ export interface CursorStatus {
/**
* 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() {
const { setCursorCliStatus } = useSetupStore();
const { data: result, isLoading, refetch } = useCursorCliStatus();
const [status, setStatus] = useState<CursorStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const api = getHttpApiClient();
const statusResult = await api.setup.getCursorStatus();
if (statusResult.success) {
const newStatus = {
installed: statusResult.installed ?? false,
version: statusResult.version ?? undefined,
authenticated: statusResult.auth?.authenticated ?? false,
method: statusResult.auth?.method,
};
setStatus(newStatus);
// Also update the global setup store so other components can access the status
setCursorCliStatus({
installed: newStatus.installed,
version: newStatus.version,
auth: newStatus.authenticated
? {
authenticated: true,
method: newStatus.method || 'unknown',
}
: undefined,
});
}
} catch (error) {
logger.error('Failed to load Cursor settings:', error);
toast.error('Failed to load Cursor settings');
} finally {
setIsLoading(false);
}
}, [setCursorCliStatus]);
// Transform the API result into the local CursorStatus shape
const status = useMemo((): CursorStatus | null => {
if (!result) return null;
return {
installed: result.installed ?? false,
version: result.version ?? undefined,
authenticated: result.auth?.authenticated ?? false,
method: result.auth?.method,
};
}, [result]);
// Keep the global setup store in sync with query data
useEffect(() => {
loadData();
}, [loadData]);
if (status) {
setCursorCliStatus({
installed: status.installed,
version: status.version,
auth: status.authenticated
? {
authenticated: true,
method: status.method || 'unknown',
}
: undefined,
});
}
}, [status, setCursorCliStatus]);
const loadData = useCallback(() => {
refetch();
}, [refetch]);
return {
status,

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Skills from (user/project).
*/
import { useState } from 'react';
import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSkillsSettings() {
const enabled = useAppStore((state) => state.enableSkills);
const sources = useAppStore((state) => state.skillsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSkills: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSkills: newEnabled });
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
} catch (error) {
toast.error('Failed to update skills settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
// React Query mutation (disable default toast)
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
const updateSources = async (newSources: Array<'user' | 'project'>) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ skillsSources: newSources });
// Update local store after successful server update
useAppStore.setState({ skillsSources: newSources });
toast.success('Skills sources updated');
} catch (error) {
toast.error('Failed to update skills sources');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateEnabled = useCallback(
(newEnabled: boolean) => {
updateSettingsMutation.mutate(
{ enableSkills: newEnabled },
{
onSuccess: () => {
useAppStore.setState({ enableSkills: newEnabled });
toast.success(newEnabled ? 'Skills enabled' : 'Skills disabled');
},
}
);
},
[updateSettingsMutation]
);
const updateSources = useCallback(
(newSources: Array<'user' | 'project'>) => {
updateSettingsMutation.mutate(
{ skillsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ skillsSources: newSources });
toast.success('Skills sources updated');
},
}
);
},
[updateSettingsMutation]
);
return {
enabled,
sources,
updateEnabled,
updateSources,
isLoading,
isLoading: updateSettingsMutation.isPending,
};
}

View File

@@ -5,59 +5,53 @@
* configuring which sources to load Subagents from (user/project).
*/
import { useState } from 'react';
import { useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { useUpdateGlobalSettings } from '@/hooks/mutations';
export function useSubagentsSettings() {
const enabled = useAppStore((state) => state.enableSubagents);
const sources = useAppStore((state) => state.subagentsSources);
const [isLoading, setIsLoading] = useState(false);
const updateEnabled = async (newEnabled: boolean) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ enableSubagents: newEnabled });
// Update local store after successful server update
useAppStore.setState({ enableSubagents: newEnabled });
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
} catch (error) {
toast.error('Failed to update subagents settings');
console.error(error);
} finally {
setIsLoading(false);
}
};
// React Query mutation (disable default toast)
const updateSettingsMutation = useUpdateGlobalSettings({ showSuccessToast: false });
const updateSources = async (newSources: Array<'user' | 'project'>) => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
throw new Error('Settings API not available');
}
await api.settings.updateGlobal({ subagentsSources: newSources });
// Update local store after successful server update
useAppStore.setState({ subagentsSources: newSources });
toast.success('Subagents sources updated');
} catch (error) {
toast.error('Failed to update subagents sources');
console.error(error);
} finally {
setIsLoading(false);
}
};
const updateEnabled = useCallback(
(newEnabled: boolean) => {
updateSettingsMutation.mutate(
{ enableSubagents: newEnabled },
{
onSuccess: () => {
useAppStore.setState({ enableSubagents: newEnabled });
toast.success(newEnabled ? 'Subagents enabled' : 'Subagents disabled');
},
}
);
},
[updateSettingsMutation]
);
const updateSources = useCallback(
(newSources: Array<'user' | 'project'>) => {
updateSettingsMutation.mutate(
{ subagentsSources: newSources },
{
onSuccess: () => {
useAppStore.setState({ subagentsSources: newSources });
toast.success('Subagents sources updated');
},
}
);
},
[updateSettingsMutation]
);
return {
enabled,
sources,
updateEnabled,
updateSources,
isLoading,
isLoading: updateSettingsMutation.isPending,
};
}

View File

@@ -9,10 +9,12 @@
* 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 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 SubagentType = 'filesystem';
@@ -35,51 +37,40 @@ interface FilesystemAgent {
}
export function useSubagents() {
const queryClient = useQueryClient();
const currentProject = useAppStore((state) => state.currentProject);
const [isLoading, setIsLoading] = useState(false);
const [subagentsWithScope, setSubagentsWithScope] = useState<SubagentWithScope[]>([]);
// Fetch filesystem agents
const fetchFilesystemAgents = useCallback(async () => {
setIsLoading(true);
try {
const api = getElectronAPI();
if (!api.settings) {
console.warn('Settings API not available');
return;
}
const data = await api.settings.discoverAgents(currentProject?.path, ['user', 'project']);
// Use React Query hook for fetching agents
const {
data: agents = [],
isLoading,
refetch,
} = useDiscoveredAgents(currentProject?.path, ['user', 'project']);
if (data.success && data.agents) {
// Transform filesystem agents to SubagentWithScope format
const agents: SubagentWithScope[] = data.agents.map(
({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
})
);
setSubagentsWithScope(agents);
}
} catch (error) {
console.error('Failed to fetch filesystem agents:', error);
} finally {
setIsLoading(false);
}
}, [currentProject?.path]);
// Transform agents to SubagentWithScope format
const subagentsWithScope = useMemo((): SubagentWithScope[] => {
return agents.map(({ name, definition, source, filePath }: FilesystemAgent) => ({
name,
definition,
scope: source === 'user' ? 'global' : 'project',
type: 'filesystem' as const,
source,
filePath,
}));
}, [agents]);
// Fetch filesystem agents on mount and when project changes
useEffect(() => {
fetchFilesystemAgents();
}, [fetchFilesystemAgents]);
// Refresh function that invalidates the query cache
const refreshFilesystemAgents = useCallback(async () => {
await queryClient.invalidateQueries({
queryKey: queryKeys.settings.agents(currentProject?.path ?? ''),
});
await refetch();
}, [queryClient, currentProject?.path, refetch]);
return {
subagentsWithScope,
isLoading,
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 { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { ProviderToggle } from './provider-toggle';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { useOpencodeCliStatus, useOpencodeProviders, useOpencodeModels } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
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() {
const queryClient = useQueryClient();
const {
enabledOpencodeModels,
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = 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 providerRefreshSignatureRef = useRef<string>('');
// Phase 1: Load CLI status quickly on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
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,
});
}
} 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);
}
// React Query hooks for data fetching
const {
data: cliStatusData,
isLoading: isCheckingOpencodeCli,
refetch: refetchCliStatus,
} = useOpencodeCliStatus();
const isCliInstalled = cliStatusData?.installed ?? false;
const { data: providersData = [], isFetching: isFetchingProviders } = useOpencodeProviders();
const { data: modelsData = [], isFetching: isFetchingModels } = useOpencodeModels();
// Transform CLI status to the expected format
const cliStatus = useMemo((): SharedCliStatus | null => {
if (!cliStatusData) return null;
return {
success: cliStatusData.success ?? false,
status: cliStatusData.installed ? 'installed' : 'not_installed',
method: cliStatusData.auth?.method,
version: cliStatusData.version,
path: cliStatusData.path,
recommendation: cliStatusData.recommendation,
installCommands: cliStatusData.installCommands,
};
checkOpencodeStatus();
}, []);
}, [cliStatusData]);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
const needsProviders = cachedOpencodeProviders.length === 0;
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);
}
// Transform auth status to the expected format
const authStatus = useMemo((): OpencodeAuthStatus | null => {
if (!cliStatusData?.auth) return null;
return {
authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as OpencodeAuthStatus['method']) || 'none',
hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
hasOAuthToken: cliStatusData.auth.hasOAuthToken,
error: cliStatusData.auth.error,
};
loadDynamicContent();
}, [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,
]);
}, [cliStatusData]);
// Refresh all opencode-related queries
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
const result = await api.setup.getOpencodeStatus();
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');
}
}
} 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]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.cli.opencode() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencodeProviders() }),
queryClient.invalidateQueries({ queryKey: queryKeys.models.opencode() }),
]);
await refetchCliStatus();
toast.success('OpenCode CLI refreshed');
}, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -241,7 +81,7 @@ export function OpencodeSettingsTab() {
try {
setOpencodeDefaultModel(model);
toast.success('Default model updated');
} catch (error) {
} catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
@@ -255,7 +95,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleOpencodeModel(model, enabled);
} catch (error) {
} catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
@@ -269,7 +109,7 @@ export function OpencodeSettingsTab() {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
} catch {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
@@ -287,7 +127,7 @@ export function OpencodeSettingsTab() {
);
}
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
const isLoadingDynamicModels = isFetchingProviders || isFetchingModels;
return (
<div className="space-y-6">
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
providers={providersData as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -310,8 +150,8 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
providers={providersData as OpenCodeProviderInfo[]}
dynamicModels={modelsData}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}

View File

@@ -10,6 +10,7 @@ import { createElement } from 'react';
import { SPEC_FILE_WRITE_DELAY, STATUS_CHECK_INTERVAL_MS } from '../constants';
import type { FeatureCount } from '../types';
import type { SpecRegenerationEvent } from '@/types/electron';
import { useCreateSpec, useRegenerateSpec, useGenerateFeatures } from '@/hooks/mutations';
interface UseSpecGenerationOptions {
loadSpec: () => Promise<void>;
@@ -18,6 +19,11 @@ interface UseSpecGenerationOptions {
export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const { currentProject } = useAppStore();
// React Query mutations
const createSpecMutation = useCreateSpec(currentProject?.path ?? '');
const regenerateSpecMutation = useRegenerateSpec(currentProject?.path ?? '');
const generateFeaturesMutation = useGenerateFeatures(currentProject?.path ?? '');
// Dialog visibility state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
@@ -427,47 +433,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
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) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start spec creation:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
createSpecMutation.mutate(
{
projectOverview: projectOverview.trim(),
generateFeatures,
analyzeProject: analyzeProjectOnCreate,
featureCount: generateFeatures ? featureCountOnCreate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to create spec:', errorMsg);
setIsCreating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectOverview,
generateFeatures,
analyzeProjectOnCreate,
featureCountOnCreate,
createSpecMutation,
]);
const handleRegenerate = useCallback(async () => {
@@ -483,47 +476,34 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
'[useSpecGeneration] Starting spec regeneration, generateFeatures:',
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) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start regeneration:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
regenerateSpecMutation.mutate(
{
projectDefinition: projectDefinition.trim(),
generateFeatures: generateFeaturesOnRegenerate,
analyzeProject: analyzeProjectOnRegenerate,
featureCount: generateFeaturesOnRegenerate ? featureCountOnRegenerate : undefined,
},
{
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
},
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error('[useSpecGeneration] Failed to regenerate spec:', errorMsg);
setIsRegenerating(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
);
}, [
currentProject,
projectDefinition,
generateFeaturesOnRegenerate,
analyzeProjectOnRegenerate,
featureCountOnRegenerate,
regenerateSpecMutation,
]);
const handleGenerateFeatures = useCallback(async () => {
@@ -536,36 +516,20 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
logsRef.current = '';
setLogs('');
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) {
const errorMsg = result.error || 'Unknown error';
logger.error('[useSpecGeneration] Failed to start feature generation:', errorMsg);
generateFeaturesMutation.mutate(undefined, {
onError: (error) => {
const errorMsg = error.message;
logger.error('[useSpecGeneration] Failed to generate features:', errorMsg);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
const errorLog = `[Error] Failed to generate features: ${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);
setIsGeneratingFeatures(false);
setCurrentPhase('error');
setErrorMessage(errorMsg);
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
logsRef.current = errorLog;
setLogs(errorLog);
}
}, [currentProject]);
},
});
}, [currentProject, generateFeaturesMutation]);
const handleSync = useCallback(async () => {
if (!currentProject) return;

View File

@@ -1,62 +1,51 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('SpecLoading');
import { getElectronAPI } from '@/lib/electron';
import { useSpecFile, useSpecRegenerationStatus } from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
export function useSpecLoading() {
const { currentProject, setAppSpec } = useAppStore();
const [isLoading, setIsLoading] = useState(true);
const queryClient = useQueryClient();
const [specExists, setSpecExists] = useState(true);
const [isGenerationRunning, setIsGenerationRunning] = useState(false);
const loadSpec = useCallback(async () => {
if (!currentProject) return;
// React Query hooks
const specFileQuery = useSpecFile(currentProject?.path);
const statusQuery = useSpecRegenerationStatus(currentProject?.path);
setIsLoading(true);
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]);
const isGenerationRunning = statusQuery.data?.isRunning ?? false;
// Update app store and specExists when spec file data changes
useEffect(() => {
loadSpec();
}, [loadSpec]);
if (specFileQuery.data && !isGenerationRunning) {
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 {
isLoading,
isLoading: specFileQuery.isLoading,
specExists,
setSpecExists,
isGenerationRunning,

View File

@@ -1,28 +1,20 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('SpecSave');
import { getElectronAPI } from '@/lib/electron';
import { useSaveSpec } from '@/hooks/mutations';
export function useSpecSave() {
const { currentProject, appSpec, setAppSpec } = useAppStore();
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// React Query mutation
const saveMutation = useSaveSpec(currentProject?.path ?? '');
const saveSpec = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
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);
}
saveMutation.mutate(appSpec, {
onSuccess: () => setHasChanges(false),
});
};
const handleChange = (value: string) => {
@@ -31,7 +23,7 @@ export function useSpecSave() {
};
return {
isSaving,
isSaving: saveMutation.isPending,
hasChanges,
setHasChanges,
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