diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md new file mode 100644 index 00000000..f12c02a3 --- /dev/null +++ b/SECURITY_TODO.md @@ -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 diff --git a/TODO.md b/TODO.md index 3771806b..4ea7cf34 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 43c65992..3c90fd38 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 () => { diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index 1bec9368..2d53c8e5 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -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 diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index 9e0ae999..1a238d17 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -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', diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index d4358b65..7459ca57 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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; } diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index c6db10fc..6c999552 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -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) { diff --git a/apps/server/src/routes/worktree/routes/list-remotes.ts b/apps/server/src/routes/worktree/routes/list-remotes.ts new file mode 100644 index 00000000..1180afce --- /dev/null +++ b/apps/server/src/routes/worktree/routes/list-remotes.ts @@ -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 => { + 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(); + 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(); + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index 69f120b8..48df7893 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -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 => { 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) }); diff --git a/apps/server/src/routes/worktree/routes/push.ts b/apps/server/src/routes/worktree/routes/push.ts index b044ba00..0e082b3f 100644 --- a/apps/server/src/routes/worktree/routes/push.ts +++ b/apps/server/src/routes/worktree/routes/push.ts @@ -15,9 +15,10 @@ const execAsync = promisify(exec); export function createPushHandler() { return async (req: Request, res: Response): Promise => { 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) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index aeaa2033..a2d7f1af 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -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) - .autoModeByWorktree; + const autoModeByWorktree = settings.autoModeByWorktree; if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { const key = `${projectId}::${branchName ?? '__main__'}`; - const entry = (autoModeByWorktree as Record)[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> | 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 { 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, })), }); diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 74070b78..9f73155f 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -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, diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 66cd038a..8c760c70 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -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 = (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) { diff --git a/apps/ui/package.json b/apps/ui/package.json index cd804908..e66433fd 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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", diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index fa3d5c94..5beaac94 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -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(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() { )} @@ -259,26 +183,16 @@ export function ClaudeUsagePopover() {
-

{error.message}

+

+ {error instanceof Error ? error.message : 'Failed to fetch usage'} +

- {error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( - 'Ensure the Electron bridge is running or restart the app' - ) : error.code === ERROR_CODES.TRUST_PROMPT ? ( - <> - Run claude in your - terminal and approve access to continue - - ) : ( - <> - Make sure Claude CLI is installed and authenticated via{' '} - claude login - - )} + Make sure Claude CLI is installed and authenticated via{' '} + claude login

- ) : !claudeUsage ? ( - // Loading state + ) : isLoading || !claudeUsage ? (

Loading usage data...

diff --git a/apps/ui/src/components/codex-usage-popover.tsx b/apps/ui/src/components/codex-usage-popover.tsx index 0fee4226..430ccdfa 100644 --- a/apps/ui/src/components/codex-usage-popover.tsx +++ b/apps/ui/src/components/codex-usage-popover.tsx @@ -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(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() { )}
diff --git a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx index 84e723fc..9a54f7ab 100644 --- a/apps/ui/src/components/dialogs/workspace-picker-modal.tsx +++ b/apps/ui/src/components/dialogs/workspace-picker-modal.tsx @@ -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([]); - const [error, setError] = useState(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 ( @@ -80,19 +53,19 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace )} - {error && !isLoading && ( + {errorMessage && !isLoading && (
-

{error}

-
)} - {!isLoading && !error && directories.length === 0 && ( + {!isLoading && !errorMessage && directories.length === 0 && (
@@ -103,7 +76,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
)} - {!isLoading && !error && directories.length > 0 && ( + {!isLoading && !errorMessage && directories.length > 0 && (
{directories.map((dir) => ( @@ -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()} > diff --git a/apps/ui/src/components/views/analysis-view.tsx b/apps/ui/src/components/views/analysis-view.tsx index 2143d390..ff1745e3 100644 --- a/apps/ui/src/components/views/analysis-view.tsx +++ b/apps/ui/src/components/views/analysis-view.tsx @@ -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(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) => { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index c72fc8de..7b55cb60 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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['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[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 = {}; @@ -1174,133 +1317,148 @@ export function BoardView() { onViewModeChange={setViewMode} /> - {/* Worktree Panel - conditionally rendered based on visibility setting */} - {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( - 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 */} -
- {/* View Content - Kanban Board or List View */} - {isListView ? ( - 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 */} + + {/* Worktree Panel - conditionally rendered based on visibility setting */} + {(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( + 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" - /> - ) : ( - 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, + }))} /> )} -
+ + {/* Main Content Area */} +
+ {/* View Content - Kanban Board or List View */} + {isListView ? ( + 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" + /> + ) : ( + 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" + /> + )} +
+ {/* Selection Action Bar */} {isSelectionMode && ( @@ -1394,6 +1552,15 @@ export function BoardView() { forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} /> + {/* Dependency Link Dialog */} + !open && clearPendingDependencyLink()} + draggedFeature={pendingDependencyLink?.draggedFeature || null} + targetFeature={pendingDependencyLink?.targetFeature || null} + onLink={handleCreateDependencyLink} + /> + {/* Edit Feature Dialog */} @@ -1560,33 +1732,12 @@ export function BoardView() { }} /> - {/* Merge Worktree Dialog */} - 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 */} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 453c94e3..9cd9d793 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -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(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 >(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} /> ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 7dfa4bef..0151a798 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -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({ )}
); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx index 268e67be..e2673415 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -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) {
); -} +}); 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) { )} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx index 237c0a7e..5b2229d8 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -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 })()} ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 73d1dc3a..87a26cdf 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -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({ /> ); -} +}); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index ab640c21..ea078dd6 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -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 */} ; + onScroll?: (event: UIEvent) => 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 */}
{children}
diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx index cca4e474..c8b9e430 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-header.tsx @@ -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 diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx index f3877906..a3d10eb7 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-row.tsx @@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
+ {/* Priority column */} +
+ {feature.priority ? ( + + {feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'} + + ) : ( + - + )} +
+ {/* Actions column */}
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index cfb34f18..6db3df66 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -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(''); - 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(''); const [viewMode, setViewMode] = useState(null); - const [projectPath, setProjectPath] = useState(''); + + // 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(null); const autoScrollRef = useRef(true); - const projectPathRef = useRef(''); 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 && ( )} {effectiveViewMode === 'changes' ? (
- {projectPath ? ( + {resolvedProjectPath ? ( (null); const [browserUrl, setBrowserUrl] = useState(null); const [showBrowserFallback, setShowBrowserFallback] = useState(false); - // Branch fetching state - const [branches, setBranches] = useState([]); - 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; diff --git a/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx new file mode 100644 index 00000000..152e6702 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/dependency-link-dialog.tsx @@ -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 ( + + + + + + Link Features + + + Create a dependency relationship between these features. + + + +
+ {/* Dragged feature */} +
+
Dragged Feature
+
+ {draggedFeature.description} +
+
{draggedFeature.category}
+
+ + {/* Arrow indicating direction */} +
+ +
+ + {/* Target feature */} +
+
Target Feature
+
+ {targetFeature.description} +
+
{targetFeature.category}
+
+ + {/* Existing link warning */} + {existingLink && ( +
+ {draggedDependsOnTarget + ? 'The dragged feature already depends on the target feature.' + : 'The target feature already depends on the dragged feature.'} +
+ )} +
+ + + {/* Set as Parent - top */} + + {/* Set as Child - middle */} + + {/* Cancel - bottom */} + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 84027daf..419f1004 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx index e5a255f3..7bb1440a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/merge-worktree-dialog.tsx @@ -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('confirm'); - const [confirmText, setConfirmText] = useState(''); + const [targetBranch, setTargetBranch] = useState('main'); + const [availableBranches, setAvailableBranches] = useState([]); + const [loadingBranches, setLoadingBranches] = useState(false); + const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false); + const [mergeConflict, setMergeConflict] = useState(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 ( - - Merge to Main + + Merge Conflicts Detected -
+
- Merge branch{' '} - {worktree.branch} into - main? + There are conflicts when merging{' '} + + {mergeConflict.sourceBranch} + {' '} + into{' '} + + {mergeConflict.targetBranch} + + . -
- This will: -
    -
  • Merge the branch into the main branch
  • -
  • Remove the worktree directory
  • -
  • Delete the branch
  • -
+
+ + + The merge could not be completed automatically. You can create a feature task to + resolve the conflicts in the{' '} + + {mergeConflict.targetBranch} + {' '} + branch. +
- {worktree.hasChanges && ( -
- - - This worktree has {worktree.changedFilesCount} uncommitted change(s). Please - commit or discard them before merging. - -
- )} - - {affectedFeatureCount > 0 && ( -
- - - {affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '} - {affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will - be unassigned after merge. - -
- )} +
+

+ This will create a high-priority feature task that will: +

+
    +
  • + Resolve merge conflicts in the{' '} + + {mergeConflict.targetBranch} + {' '} + branch +
  • +
  • Ensure the code compiles and tests pass
  • +
  • Complete the merge automatically
  • +
+
- + @@ -168,52 +246,86 @@ export function MergeWorktreeDialog({ ); } - // Second step: Type confirmation return ( - - Confirm Merge + + Merge Branch
-
- - - This action cannot be undone. The branch{' '} - {worktree.branch} will be - permanently deleted after merging. - -
+ + Merge {worktree.branch}{' '} + into: +
-
+ + {worktree.hasChanges && ( +
+ + + This worktree has {worktree.changedFilesCount} uncommitted change(s). Please + commit or discard them before merging. + +
+ )}
+
+ setDeleteWorktreeAndBranch(checked === true)} + /> + +
+ + {deleteWorktreeAndBranch && ( +
+ + + The worktree and branch will be permanently deleted. Any features assigned to this + branch will be unassigned. + +
+ )} + - diff --git a/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx new file mode 100644 index 00000000..a4bd44f4 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx @@ -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([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [selectedBranch, setSelectedBranch] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(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 ( + + + + + + Pull & Resolve Conflicts + + + Select a remote branch to pull from and resolve conflicts with{' '} + + {worktree?.branch || 'current branch'} + + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ +
+ + + {selectedRemote && branches.length === 0 && ( +

No branches found for this remote

+ )} +
+ + {selectedBranch && ( +
+

+ This will create a feature task to pull from{' '} + {selectedBranch} into{' '} + {worktree?.branch} and resolve + any merge conflicts. +

+
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx new file mode 100644 index 00000000..4e02b4e1 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx @@ -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([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(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 ( + + + + + + Push New Branch to Remote + + + new + + + + Push{' '} + + {worktree?.branch || 'current branch'} + {' '} + to a remote repository for the first time. + + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ + {selectedRemote && ( +
+

+ This will create a new remote branch{' '} + + {selectedRemote}/{worktree?.branch} + {' '} + and set up tracking. +

+
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index de9e87ac..a2caef8a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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( diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 3d92139d..6505da2a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -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); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index 466d7cca..25d0451a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -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(null); - const { moveFeature } = useAppStore(); + const [pendingDependencyLink, setPendingDependencyLink] = useState( + 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, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts index 1a7eda53..df352b01 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts @@ -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(() => { diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 68d746c5..ebdd5034 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -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([]); - // Track previous project path to detect project switches - const prevProjectPathRef = useRef(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, }; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 9ce47c83..4c809631 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -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( + 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 { diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 6ace0e76..8314e74f 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -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 { + contentRef: RefObject; + onScroll: (event: UIEvent) => void; + itemIds: string[]; + visibleItems: Item[]; + totalHeight: number; + offsetTop: number; + startIndex: number; + shouldVirtualize: boolean; + registerItem: (id: string) => (node: HTMLDivElement | null) => void; +} + +interface VirtualizedListProps { + items: Item[]; + isDragging: boolean; + estimatedItemHeight: number; + itemGap: number; + overscan: number; + virtualizationThreshold: number; + children: (state: VirtualListState) => 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({ + items, + isDragging, + estimatedItemHeight, + itemGap, + overscan, + virtualizationThreshold, + children, +}: VirtualizedListProps) { + const contentRef = useRef(null); + const measurementsRef = useRef>(new Map()); + const scrollRafRef = useRef(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) => { + 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} > - -
- {columns.map((column) => { - const columnFeatures = getColumnFeatures(column.id as ColumnId); - return ( - - {columnFeatures.length > 0 && ( +
+ {columns.map((column) => { + const columnFeatures = getColumnFeatures(column.id as ColumnId); + return ( + + {({ + contentRef, + onScroll, + itemIds, + visibleItems, + totalHeight, + offsetTop, + startIndex, + shouldVirtualize, + registerItem, + }) => ( + + {columnFeatures.length > 0 && ( + + )} - )} +
+ ) : column.id === 'backlog' ? ( +
+ + +
+ ) : column.id === 'waiting_approval' ? ( -
- ) : column.id === 'backlog' ? ( -
- - -
- ) : column.id === 'waiting_approval' ? ( - - ) : column.id === 'in_progress' ? ( - - ) : column.isPipelineStep ? ( - - ) : undefined - } - footerAction={ - column.id === 'backlog' ? ( - - ) : undefined - } - > - f.id)} - strategy={verticalListSortingStrategy} + ) : column.id === 'in_progress' ? ( + + ) : column.isPipelineStep ? ( + + ) : undefined + } + footerAction={ + column.id === 'backlog' ? ( + + ) : undefined + } > - {/* Empty state card when column has no features */} - {columnFeatures.length === 0 && !isDragging && ( - - )} - {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 ( - 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)} - /> - ); - })} - - - ); - })} -
+ {(() => { + const reduceEffects = shouldVirtualize; + const effectiveCardOpacity = reduceEffects + ? Math.min(backgroundSettings.cardOpacity, REDUCED_CARD_OPACITY_PERCENT) + : backgroundSettings.cardOpacity; + const effectiveGlassmorphism = + backgroundSettings.cardGlassmorphism && !reduceEffects; - - {activeFeature && ( -
- {}} - 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} - /> -
- )} -
- + return ( + + {/* Empty state card when column has no features */} + {columnFeatures.length === 0 && !isDragging && ( + + )} + {shouldVirtualize ? ( +
+
+ {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 ( +
+ 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)} + /> +
+ ); + })} +
+
+ ) : ( + columnFeatures.map((feature, index) => { + let shortcutKey: string | undefined; + if (column.id === 'in_progress' && index < 10) { + shortcutKey = index === 9 ? '0' : String(index + 1); + } + return ( + 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)} + /> + ); + }) + )} +
+ ); + })()} + + )} + + ); + })} +
+ + + {activeFeature && ( +
+ {}} + 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} + /> +
+ )} +
); } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index f33ceba8..8ba682d9 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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({ 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')} > {isPushing ? 'Pushing...' : 'Push'} {!canPerformGitOps && } - {canPerformGitOps && aheadCount > 0 && ( + {canPerformGitOps && !hasRemoteBranch && ( + + + new + + )} + {canPerformGitOps && hasRemoteBranch && aheadCount > 0 && ( {aheadCount} ahead @@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({ {!canPerformGitOps && } - {!worktree.isMain && ( - - canPerformGitOps && onMerge(worktree)} - disabled={!canPerformGitOps} - className={cn( - 'text-xs text-green-600 focus:text-green-700', - !canPerformGitOps && 'opacity-50 cursor-not-allowed' - )} - > - - Merge to Main - {!canPerformGitOps && ( - - )} - - - )} {/* Open in editor - split button: click main area for default, chevron for other options */} {effectiveDefaultEditor && ( @@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({ )} {!worktree.isMain && ( <> + + canPerformGitOps && onMerge(worktree)} + disabled={!canPerformGitOps} + className={cn( + 'text-xs text-green-600 focus:text-green-700', + !canPerformGitOps && 'opacity-50 cursor-not-allowed' + )} + > + + Merge Branch + {!canPerformGitOps && ( + + )} + + + onDeleteWorktree(worktree)} className="text-xs text-destructive focus:text-destructive" diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 6c05bf8c..d8a57ced 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -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 ( -
+
{worktree.isMain ? ( <>
); } @@ -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 */} + + + {/* Merge Branch Dialog */} +
); } diff --git a/apps/ui/src/components/views/chat-history.tsx b/apps/ui/src/components/views/chat-history.tsx index e6939361..eed0b062 100644 --- a/apps/ui/src/components/views/chat-history.tsx +++ b/apps/ui/src/components/views/chat-history.tsx @@ -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(null); + const scrollRafRef = useRef(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) => { + 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() {
{/* Chat Sessions List */} -
+
{sortedSessions.length === 0 ? (
{searchQuery ? ( @@ -163,60 +261,75 @@ export function ChatHistory() { )}
) : ( -
- {sortedSessions.map((session) => ( -
handleSelectSession(session)} - > -
-

{session.title}

-

- {session.messages.length} messages -

-

- {new Date(session.updatedAt).toLocaleDateString()} -

-
+
+
+ {visibleSessions.map((session) => ( +
handleSelectSession(session)} + > +
+

{session.title}

+

+ {session.messages.length} messages +

+

+ {new Date(session.updatedAt).toLocaleDateString()} +

+
-
- - - - - - {session.archived ? ( +
+ + + + + + {session.archived ? ( + handleUnarchiveSession(session.id, e)} + > + + Unarchive + + ) : ( + handleArchiveSession(session.id, e)} + > + + Archive + + )} + handleUnarchiveSession(session.id, e)} + onClick={(e) => handleDeleteSession(session.id, e)} + className="text-destructive" > - - Unarchive + + Delete - ) : ( - handleArchiveSession(session.id, e)}> - - Archive - - )} - - handleDeleteSession(session.id, e)} - className="text-destructive" - > - - Delete - - - + + +
-
- ))} + ))} +
)}
diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 0ae6e1e8..986ad65c 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -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(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) { diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts index 0083a877..a97667f1 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -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([]); - const [closedIssues, setClosedIssues] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(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, }; } diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts index 7ae1b130..44f36ac8 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts @@ -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([]); - const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [hasNextPage, setHasNextPage] = useState(false); - const [endCursor, setEndCursor] = useState(undefined); - const [error, setError] = useState(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, }; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts index c09baab0..788a9efe 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -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(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(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, }; } diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx index fbbcb9eb..0a9b3417 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -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([]); - const [mergedPRs, setMergedPRs] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [selectedPR, setSelectedPR] = useState(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() {

Failed to Load Pull Requests

-

{error}

+

+ {error instanceof Error ? error.message : 'Failed to fetch pull requests'} +

+
+ + )} + + ); + } + return ( <> {/* Invisible wider path for hover detection */} diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index 020b1914..16cf6817 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -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 ( + <> + + +
+
+
+ + {config.label} + {priorityConf && ( + + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} + + )} +
+
+

+ {data.title || data.description} +

+ {data.title && data.description && ( +

+ {data.description} +

+ )} + {data.isRunning && ( +
+ + Running +
+ )} + {isStopped && ( +
+ + Paused +
+ )} +
+
+ + + + ); + } + return ( <> {/* Target handle (left side - receives dependencies) */} diff --git a/apps/ui/src/components/views/graph-view/constants.ts b/apps/ui/src/components/views/graph-view/constants.ts new file mode 100644 index 00000000..d75b6ea8 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/constants.ts @@ -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; diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index f14f3120..1286a745 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -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 (
diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index 245894ab..e84bb1d5 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -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 }); diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts index 8349bff6..e769e4e3 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts @@ -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): void { +function getDescendants( + featureId: string, + dependentsMap: Map, + visited: Set +): 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 { + const dependentsMap = new Map(); + 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, 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): 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(); 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 diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index 3e9e41e0..3b902611 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -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; @@ -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(); - - // 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); diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx index af52030b..a402b8d1 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -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(null); const [startedPrompts, setStartedPrompts] = useState>(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 ( diff --git a/apps/ui/src/components/views/running-agents-view.tsx b/apps/ui/src/components/views/running-agents-view.tsx index 883609db..4265650b 100644 --- a/apps/ui/src/components/views/running-agents-view.tsx +++ b/apps/ui/src/components/views/running-agents-view.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); const [selectedAgent, setSelectedAgent] = useState(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 (
@@ -169,8 +112,8 @@ export function RunningAgentsView() {

- - diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 2aa1ff3c..d2300c88 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -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(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 (
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 ? : } + {isFetching ? : }

{CLAUDE_USAGE_SUBTITLE}

@@ -195,10 +144,10 @@ export function ClaudeUsageSection() {
)} - {error && !showAuthWarning && ( + {errorMessage && !showAuthWarning && (
-
{error}
+
{errorMessage}
)} @@ -220,7 +169,7 @@ export function ClaudeUsageSection() {
)} - {!hasUsage && !error && !showAuthWarning && !isLoading && ( + {!hasUsage && !errorMessage && !showAuthWarning && !isLoading && (
{CLAUDE_NO_USAGE_MESSAGE}
diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index a6474a7a..9836f76e 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -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
; -} - function ClaudeCliStatusSkeleton() { return (
; -} - function CodexCliStatusSkeleton() { return (
void; } -function SkeletonPulse({ className }: { className?: string }) { - return
; -} - export function CursorCliStatusSkeleton() { return (
void; } -function SkeletonPulse({ className }: { className?: string }) { - return
; -} - export function OpencodeCliStatusSkeleton() { return (
state.codexAuthStatus); - const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); - const [error, setError] = useState(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() {

{CODEX_USAGE_SUBTITLE}

@@ -183,10 +139,10 @@ export function CodexUsageSection() {
)} - {error && ( + {errorMessage && (
-
{error}
+
{errorMessage}
)} {hasMetrics && ( @@ -211,7 +167,7 @@ export function CodexUsageSection() {
)} - {!hasMetrics && !error && canFetchUsage && !isLoading && ( + {!hasMetrics && !errorMessage && canFetchUsage && !isLoading && (
{CODEX_NO_USAGE_MESSAGE}
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts index a911892e..a7327686 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-permissions.ts @@ -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(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, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts index a082e71b..6a39f7ca 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cursor-status.ts @@ -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(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, diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts index 233e0fdd..3542b951 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-skills-settings.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts index ccf7664a..dfc55cd0 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents-settings.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts index 50f82393..475f8378 100644 --- a/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts +++ b/apps/ui/src/components/views/settings-view/providers/claude-settings-tab/hooks/use-subagents.ts @@ -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([]); - // 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, }; } diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx index 0ec718a3..4321b6d8 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx @@ -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(null); - const [authStatus, setAuthStatus] = useState(null); const [isSaving, setIsSaving] = useState(false); - const providerRefreshSignatureRef = useRef(''); - // 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 (
@@ -297,7 +137,7 @@ export function OpencodeSettingsTab() { @@ -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} diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts index 30a8150f..6cf7bf50 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-generation.ts @@ -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; @@ -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; diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts index 9fc09b81..5aff3df4 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-loading.ts @@ -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, diff --git a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts index 5b0bbb47..03812fd3 100644 --- a/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts +++ b/apps/ui/src/components/views/spec-view/hooks/use-spec-save.ts @@ -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, diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts new file mode 100644 index 00000000..9cab4bea --- /dev/null +++ b/apps/ui/src/hooks/mutations/index.ts @@ -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'; diff --git a/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts new file mode 100644 index 00000000..0eb07a1d --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts @@ -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, + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts new file mode 100644 index 00000000..3b813d2e --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-cursor-permissions-mutations.ts @@ -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', + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-feature-mutations.ts b/apps/ui/src/hooks/mutations/use-feature-mutations.ts new file mode 100644 index 00000000..0b8c4e84 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-feature-mutations.ts @@ -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; + 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( + queryKeys.features.all(projectPath) + ); + + // Optimistically update the cache + if (previousFeatures) { + queryClient.setQueryData( + 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( + queryKeys.features.all(projectPath) + ); + + if (previousFeatures) { + queryClient.setQueryData( + 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 }>) => { + 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( + queryKeys.features.all(projectPath) + ); + + if (previousFeatures) { + const updatesMap = new Map(updates.map((u) => [u.featureId, u.updates])); + queryClient.setQueryData( + 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), + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-github-mutations.ts b/apps/ui/src/hooks/mutations/use-github-mutations.ts new file mode 100644 index 00000000..29395cb3 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-github-mutations.ts @@ -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 ?? []; + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts new file mode 100644 index 00000000..61841d9e --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts @@ -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 => { + 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 + }); +} diff --git a/apps/ui/src/hooks/mutations/use-settings-mutations.ts b/apps/ui/src/hooks/mutations/use-settings-mutations.ts new file mode 100644 index 00000000..aa1862ed --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-settings-mutations.ts @@ -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) => { + 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 + | { projectPath: string; settings: Record } + ) => { + // Support both call patterns: + // 1. useUpdateProjectSettings(projectPath) then mutate(settings) + // 2. useUpdateProjectSettings() then mutate({ projectPath, settings }) + let path: string; + let settings: Record; + + 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) => { + 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, + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-spec-mutations.ts b/apps/ui/src/hooks/mutations/use-spec-mutations.ts new file mode 100644 index 00000000..a9e890c0 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-spec-mutations.ts @@ -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', + }); + }, + }); +} diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts new file mode 100644 index 00000000..ec8dd6e0 --- /dev/null +++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts @@ -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, + }); + }, + }); +} diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts new file mode 100644 index 00000000..18e38120 --- /dev/null +++ b/apps/ui/src/hooks/queries/index.ts @@ -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'; diff --git a/apps/ui/src/hooks/queries/use-cli-status.ts b/apps/ui/src/hooks/queries/use-cli-status.ts new file mode 100644 index 00000000..71ea2ae9 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cli-status.ts @@ -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 + }); +} diff --git a/apps/ui/src/hooks/queries/use-cursor-permissions.ts b/apps/ui/src/hooks/queries/use-cursor-permissions.ts new file mode 100644 index 00000000..5d2e24f0 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-cursor-permissions.ts @@ -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 => { + 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, + }); +} diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts new file mode 100644 index 00000000..78db6101 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -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 => { + 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 => { + 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 => { + 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, + }); +} diff --git a/apps/ui/src/hooks/queries/use-git.ts b/apps/ui/src/hooks/queries/use-git.ts new file mode 100644 index 00000000..ef4be5ca --- /dev/null +++ b/apps/ui/src/hooks/queries/use-git.ts @@ -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, + }); +} diff --git a/apps/ui/src/hooks/queries/use-github.ts b/apps/ui/src/hooks/queries/use-github.ts new file mode 100644 index 00000000..47c3de7c --- /dev/null +++ b/apps/ui/src/hooks/queries/use-github.ts @@ -0,0 +1,184 @@ +/** + * GitHub Query Hooks + * + * React Query hooks for fetching GitHub issues, PRs, and validations. + */ + +import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; +import type { GitHubIssue, GitHubPR, GitHubComment, IssueValidation } from '@/lib/electron'; + +interface GitHubIssuesResult { + openIssues: GitHubIssue[]; + closedIssues: GitHubIssue[]; +} + +interface GitHubPRsResult { + openPRs: GitHubPR[]; + mergedPRs: GitHubPR[]; +} + +/** + * Fetch GitHub issues for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and closed issues + * + * @example + * ```tsx + * const { data, isLoading } = useGitHubIssues(currentProject?.path); + * const { openIssues, closedIssues } = data ?? { openIssues: [], closedIssues: [] }; + * ``` + */ +export function useGitHubIssues(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.issues(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listIssues(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch issues'); + } + return { + openIssues: result.openIssues ?? [], + closedIssues: result.closedIssues ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub PRs for a project + * + * @param projectPath - Path to the project + * @returns Query result with open and merged PRs + */ +export function useGitHubPRs(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.prs(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.listPRs(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch PRs'); + } + return { + openPRs: result.openPRs ?? [], + mergedPRs: result.mergedPRs ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch GitHub validations for a project + * + * @param projectPath - Path to the project + * @param issueNumber - Optional issue number to filter by + * @returns Query result with validations + */ +export function useGitHubValidations(projectPath: string | undefined, issueNumber?: number) { + return useQuery({ + queryKey: issueNumber + ? queryKeys.github.validation(projectPath ?? '', issueNumber) + : queryKeys.github.validations(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.getValidations(projectPath, issueNumber); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch validations'); + } + return result.validations ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Check GitHub remote for a project + * + * @param projectPath - Path to the project + * @returns Query result with remote info + */ +export function useGitHubRemote(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.github.remote(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.github.checkRemote(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to check remote'); + } + return { + hasRemote: result.hasRemote ?? false, + owner: result.owner, + repo: result.repo, + url: result.url, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.GITHUB, + }); +} + +/** + * Fetch comments for a GitHub issue with pagination support + * + * Uses useInfiniteQuery for proper "load more" pagination. + * + * @param projectPath - Path to the project + * @param issueNumber - Issue number + * @returns Infinite query result with comments and pagination helpers + * + * @example + * ```tsx + * const { + * data, + * isLoading, + * isFetchingNextPage, + * hasNextPage, + * fetchNextPage, + * refetch, + * } = useGitHubIssueComments(projectPath, issueNumber); + * + * // Get all comments flattened + * const comments = data?.pages.flatMap(page => page.comments) ?? []; + * ``` + */ +export function useGitHubIssueComments( + projectPath: string | undefined, + issueNumber: number | undefined +) { + return useInfiniteQuery({ + queryKey: queryKeys.github.issueComments(projectPath ?? '', issueNumber ?? 0), + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { + if (!projectPath || !issueNumber) throw new Error('Missing project path or issue number'); + const api = getElectronAPI(); + const result = await api.github.getIssueComments(projectPath, issueNumber, pageParam); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch comments'); + } + return { + comments: (result.comments ?? []) as GitHubComment[], + totalCount: result.totalCount ?? 0, + hasNextPage: result.hasNextPage ?? false, + endCursor: result.endCursor as string | undefined, + }; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.endCursor : undefined), + enabled: !!projectPath && !!issueNumber, + staleTime: STALE_TIMES.GITHUB, + }); +} diff --git a/apps/ui/src/hooks/queries/use-ideation.ts b/apps/ui/src/hooks/queries/use-ideation.ts new file mode 100644 index 00000000..aa2bd023 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-ideation.ts @@ -0,0 +1,86 @@ +/** + * Ideation Query Hooks + * + * React Query hooks for fetching ideation prompts and ideas. + */ + +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 ideation prompts + * + * @returns Query result with prompts and categories + * + * @example + * ```tsx + * const { data, isLoading, error } = useIdeationPrompts(); + * const { prompts, categories } = data ?? { prompts: [], categories: [] }; + * ``` + */ +export function useIdeationPrompts() { + return useQuery({ + queryKey: queryKeys.ideation.prompts(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.ideation?.getPrompts(); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch prompts'); + } + return { + prompts: result.prompts ?? [], + categories: result.categories ?? [], + }; + }, + staleTime: STALE_TIMES.SETTINGS, // Prompts rarely change + }); +} + +/** + * Fetch ideas for a project + * + * @param projectPath - Path to the project + * @returns Query result with ideas array + */ +export function useIdeas(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.ideas(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.ideation?.listIdeas(projectPath); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch ideas'); + } + return result.ideas ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.FEATURES, + }); +} + +/** + * Fetch a single idea by ID + * + * @param projectPath - Path to the project + * @param ideaId - ID of the idea + * @returns Query result with single idea + */ +export function useIdea(projectPath: string | undefined, ideaId: string | undefined) { + return useQuery({ + queryKey: queryKeys.ideation.idea(projectPath ?? '', ideaId ?? ''), + queryFn: async () => { + if (!projectPath || !ideaId) throw new Error('Missing project path or idea ID'); + const api = getElectronAPI(); + const result = await api.ideation?.getIdea(projectPath, ideaId); + if (!result?.success) { + throw new Error(result?.error || 'Failed to fetch idea'); + } + return result.idea; + }, + enabled: !!projectPath && !!ideaId, + staleTime: STALE_TIMES.FEATURES, + }); +} diff --git a/apps/ui/src/hooks/queries/use-models.ts b/apps/ui/src/hooks/queries/use-models.ts new file mode 100644 index 00000000..d917492b --- /dev/null +++ b/apps/ui/src/hooks/queries/use-models.ts @@ -0,0 +1,134 @@ +/** + * Models Query Hooks + * + * React Query hooks for fetching available AI models. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface CodexModel { + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; +} + +interface OpencodeModel { + id: string; + name: string; + modelString: string; + provider: string; + description: string; + supportsTools: boolean; + supportsVision: boolean; + tier: string; + default?: boolean; +} + +/** + * Fetch available models + * + * @returns Query result with available models + */ +export function useAvailableModels() { + return useQuery({ + queryKey: queryKeys.models.available(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.getAvailable(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch available models'); + } + return result.models ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch Codex models + * + * @param refresh - Force refresh from server + * @returns Query result with Codex models + */ +export function useCodexModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch Codex models'); + } + return (result.models ?? []) as CodexModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode models + * + * @param refresh - Force refresh from server + * @returns Query result with OpenCode models + */ +export function useOpencodeModels(refresh = false) { + return useQuery({ + queryKey: queryKeys.models.opencode(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeModels(refresh); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode models'); + } + return (result.models ?? []) as OpencodeModel[]; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch OpenCode providers + * + * @returns Query result with OpenCode providers + */ +export function useOpencodeProviders() { + return useQuery({ + queryKey: queryKeys.models.opencodeProviders(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.setup.getOpencodeProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode providers'); + } + return result.providers ?? []; + }, + staleTime: STALE_TIMES.MODELS, + }); +} + +/** + * Fetch model providers status + * + * @returns Query result with provider status + */ +export function useModelProviders() { + return useQuery({ + queryKey: queryKeys.models.providers(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.model.checkProviders(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch providers'); + } + return result.providers ?? {}; + }, + staleTime: STALE_TIMES.MODELS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-pipeline.ts b/apps/ui/src/hooks/queries/use-pipeline.ts new file mode 100644 index 00000000..916810d6 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-pipeline.ts @@ -0,0 +1,39 @@ +/** + * Pipeline Query Hooks + * + * React Query hooks for fetching pipeline configuration. + */ + +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 { PipelineConfig } from '@/store/app-store'; + +/** + * Fetch pipeline config for a project + * + * @param projectPath - Path to the project + * @returns Query result with pipeline config + * + * @example + * ```tsx + * const { data: pipelineConfig, isLoading } = usePipelineConfig(currentProject?.path); + * ``` + */ +export function usePipelineConfig(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.pipeline.config(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getHttpApiClient(); + const result = await api.pipeline.getConfig(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch pipeline config'); + } + return result.config ?? null; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-running-agents.ts b/apps/ui/src/hooks/queries/use-running-agents.ts new file mode 100644 index 00000000..75002226 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-running-agents.ts @@ -0,0 +1,66 @@ +/** + * Running Agents Query Hook + * + * React Query hook for fetching currently running agents. + * This data is invalidated by WebSocket events when agents start/stop. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI, type RunningAgent } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +const RUNNING_AGENTS_REFETCH_ON_FOCUS = false; +const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false; + +interface RunningAgentsResult { + agents: RunningAgent[]; + count: number; +} + +/** + * Fetch all currently running agents + * + * @returns Query result with running agents and total count + * + * @example + * ```tsx + * const { data, isLoading } = useRunningAgents(); + * const { agents, count } = data ?? { agents: [], count: 0 }; + * ``` + */ +export function useRunningAgents() { + return useQuery({ + queryKey: queryKeys.runningAgents.all(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.runningAgents.getAll(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch running agents'); + } + return { + agents: result.runningAgents ?? [], + count: result.totalCount ?? 0, + }; + }, + staleTime: STALE_TIMES.RUNNING_AGENTS, + // Note: Don't use refetchInterval here - rely on WebSocket invalidation + // for real-time updates instead of polling + refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS, + refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT, + }); +} + +/** + * Get running agents count + * This is a selector that derives count from the main query + * + * @returns Query result with just the count + */ +export function useRunningAgentsCount() { + const query = useRunningAgents(); + return { + ...query, + data: query.data?.count ?? 0, + }; +} diff --git a/apps/ui/src/hooks/queries/use-sessions.ts b/apps/ui/src/hooks/queries/use-sessions.ts new file mode 100644 index 00000000..001968e1 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-sessions.ts @@ -0,0 +1,86 @@ +/** + * Sessions Query Hooks + * + * React Query hooks for fetching session data. + */ + +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 { SessionListItem } from '@/types/electron'; + +/** + * Fetch all sessions + * + * @param includeArchived - Whether to include archived sessions + * @returns Query result with sessions array + * + * @example + * ```tsx + * const { data: sessions, isLoading } = useSessions(false); + * ``` + */ +export function useSessions(includeArchived = false) { + return useQuery({ + queryKey: queryKeys.sessions.all(includeArchived), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.sessions.list(includeArchived); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch sessions'); + } + return result.sessions ?? []; + }, + staleTime: STALE_TIMES.SESSIONS, + }); +} + +/** + * Fetch session history + * + * @param sessionId - ID of the session + * @returns Query result with session messages + */ +export function useSessionHistory(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.history(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.getHistory(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch session history'); + } + return { + messages: result.messages ?? [], + isRunning: result.isRunning ?? false, + }; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.FEATURES, // Session history changes during conversations + }); +} + +/** + * Fetch session message queue + * + * @param sessionId - ID of the session + * @returns Query result with queued messages + */ +export function useSessionQueue(sessionId: string | undefined) { + return useQuery({ + queryKey: queryKeys.sessions.queue(sessionId ?? ''), + queryFn: async () => { + if (!sessionId) throw new Error('No session ID'); + const api = getElectronAPI(); + const result = await api.agent.queueList(sessionId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch queue'); + } + return result.queue ?? []; + }, + enabled: !!sessionId, + staleTime: STALE_TIMES.RUNNING_AGENTS, // Queue changes frequently during use + }); +} diff --git a/apps/ui/src/hooks/queries/use-settings.ts b/apps/ui/src/hooks/queries/use-settings.ts new file mode 100644 index 00000000..cb77ff35 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-settings.ts @@ -0,0 +1,123 @@ +/** + * Settings Query Hooks + * + * React Query hooks for fetching global and project settings. + */ + +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 { GlobalSettings, ProjectSettings } from '@automaker/types'; + +/** + * Fetch global settings + * + * @returns Query result with global settings + * + * @example + * ```tsx + * const { data: settings, isLoading } = useGlobalSettings(); + * ``` + */ +export function useGlobalSettings() { + return useQuery({ + queryKey: queryKeys.settings.global(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.settings.getGlobal(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch global settings'); + } + return result.settings as GlobalSettings; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch project-specific settings + * + * @param projectPath - Path to the project + * @returns Query result with project settings + */ +export function useProjectSettings(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.settings.project(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.settings.getProject(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch project settings'); + } + return result.settings as ProjectSettings; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch settings status (migration status, etc.) + * + * @returns Query result with settings status + */ +export function useSettingsStatus() { + return useQuery({ + queryKey: queryKeys.settings.status(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getStatus(); + return result; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Fetch credentials status (masked API keys) + * + * @returns Query result with credentials info + */ +export function useCredentials() { + return useQuery({ + queryKey: queryKeys.settings.credentials(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.getCredentials(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch credentials'); + } + return result.credentials; + }, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Discover agents for a project + * + * @param projectPath - Path to the project + * @param sources - Sources to search ('user' | 'project') + * @returns Query result with discovered agents + */ +export function useDiscoveredAgents( + projectPath: string | undefined, + sources?: Array<'user' | 'project'> +) { + return useQuery({ + // Include sources in query key so different source combinations have separate caches + queryKey: queryKeys.settings.agents(projectPath ?? '', sources), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.settings.discoverAgents(projectPath, sources); + if (!result.success) { + throw new Error(result.error || 'Failed to discover agents'); + } + return result.agents ?? []; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-spec.ts b/apps/ui/src/hooks/queries/use-spec.ts new file mode 100644 index 00000000..c81dea34 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-spec.ts @@ -0,0 +1,103 @@ +/** + * Spec Query Hooks + * + * React Query hooks for fetching spec file content and regeneration status. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +interface SpecFileResult { + content: string; + exists: boolean; +} + +interface SpecRegenerationStatusResult { + isRunning: boolean; + currentPhase?: string; +} + +/** + * Fetch spec file content for a project + * + * @param projectPath - Path to the project + * @returns Query result with spec content and existence flag + * + * @example + * ```tsx + * const { data, isLoading } = useSpecFile(currentProject?.path); + * if (data?.exists) { + * console.log(data.content); + * } + * ``` + */ +export function useSpecFile(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.spec.file(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + const result = await api.readFile(`${projectPath}/.automaker/app_spec.txt`); + + if (result.success && result.content) { + return { + content: result.content, + exists: true, + }; + } + + return { + content: '', + exists: false, + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + }); +} + +/** + * Check spec regeneration status for a project + * + * @param projectPath - Path to the project + * @param enabled - Whether to enable the query (useful during regeneration) + * @returns Query result with regeneration status + * + * @example + * ```tsx + * const { data } = useSpecRegenerationStatus(projectPath, isRegenerating); + * if (data?.isRunning) { + * // Show loading indicator + * } + * ``` + */ +export function useSpecRegenerationStatus(projectPath: string | undefined, enabled = true) { + return useQuery({ + queryKey: queryKeys.specRegeneration.status(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + + const api = getElectronAPI(); + if (!api.specRegeneration) { + return { isRunning: false }; + } + + const status = await api.specRegeneration.status(projectPath); + + if (status.success) { + return { + isRunning: status.isRunning ?? false, + currentPhase: status.currentPhase, + }; + } + + return { isRunning: false }; + }, + enabled: !!projectPath && enabled, + staleTime: 5000, // Check every 5 seconds when active + refetchInterval: enabled ? 5000 : false, + }); +} diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts new file mode 100644 index 00000000..21f0267d --- /dev/null +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -0,0 +1,83 @@ +/** + * Usage Query Hooks + * + * React Query hooks for fetching Claude and Codex API usage data. + * These hooks include automatic polling for real-time usage updates. + */ + +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 { ClaudeUsage, CodexUsage } from '@/store/app-store'; + +/** Polling interval for usage data (60 seconds) */ +const USAGE_POLLING_INTERVAL = 60 * 1000; +const USAGE_REFETCH_ON_FOCUS = false; +const USAGE_REFETCH_ON_RECONNECT = false; + +/** + * Fetch Claude API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Claude usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useClaudeUsage(isPopoverOpen); + * ``` + */ +export function useClaudeUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.claude(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.claude.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch Codex API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Codex usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useCodexUsage(isPopoverOpen); + * ``` + */ +export function useCodexUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.codex(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.codex.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/queries/use-workspace.ts b/apps/ui/src/hooks/queries/use-workspace.ts new file mode 100644 index 00000000..2001e2b7 --- /dev/null +++ b/apps/ui/src/hooks/queries/use-workspace.ts @@ -0,0 +1,42 @@ +/** + * Workspace Query Hooks + * + * React Query hooks for workspace operations. + */ + +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'; + +interface WorkspaceDirectory { + name: string; + path: string; +} + +/** + * Fetch workspace directories + * + * @param enabled - Whether to enable the query + * @returns Query result with directories + * + * @example + * ```tsx + * const { data: directories, isLoading, error } = useWorkspaceDirectories(open); + * ``` + */ +export function useWorkspaceDirectories(enabled = true) { + return useQuery({ + queryKey: queryKeys.workspace.directories(), + queryFn: async (): Promise => { + const api = getHttpApiClient(); + const result = await api.workspace.getDirectories(); + if (!result.success) { + throw new Error(result.error || 'Failed to load directories'); + } + return result.directories ?? []; + }, + enabled, + staleTime: STALE_TIMES.SETTINGS, + }); +} diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts new file mode 100644 index 00000000..cc75dafe --- /dev/null +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -0,0 +1,274 @@ +/** + * Worktrees Query Hooks + * + * React Query hooks for fetching worktree data. + */ + +import { useQuery } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import { STALE_TIMES } from '@/lib/query-client'; + +const WORKTREE_REFETCH_ON_FOCUS = false; +const WORKTREE_REFETCH_ON_RECONNECT = false; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; + featureId?: string; + linkedToBranch?: string; +} + +interface RemovedWorktree { + path: string; + branch: string; +} + +interface WorktreesResult { + worktrees: WorktreeInfo[]; + removedWorktrees: RemovedWorktree[]; +} + +/** + * Fetch all worktrees for a project + * + * @param projectPath - Path to the project + * @param includeDetails - Whether to include detailed info (default: true) + * @returns Query result with worktrees array and removed worktrees + * + * @example + * ```tsx + * const { data, isLoading, refetch } = useWorktrees(currentProject?.path); + * const worktrees = data?.worktrees ?? []; + * ``` + */ +export function useWorktrees(projectPath: string | undefined, includeDetails = true) { + return useQuery({ + queryKey: queryKeys.worktrees.all(projectPath ?? ''), + queryFn: async (): Promise => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.listAll(projectPath, includeDetails); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktrees'); + } + return { + worktrees: result.worktrees ?? [], + removedWorktrees: result.removedWorktrees ?? [], + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch worktree info for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree info + */ +export function useWorktreeInfo(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.single(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getInfo(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree info'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch worktree status for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with worktree status + */ +export function useWorktreeStatus(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.status(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getStatus(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch worktree status'); + } + return result; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch worktree diffs for a specific feature + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature + * @returns Query result with files and diff content + */ +export function useWorktreeDiffs(projectPath: string | undefined, featureId: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.diffs(projectPath ?? '', featureId ?? ''), + queryFn: async () => { + if (!projectPath || !featureId) throw new Error('Missing project path or feature ID'); + const api = getElectronAPI(); + const result = await api.worktree.getDiffs(projectPath, featureId); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch diffs'); + } + return { + files: result.files ?? [], + diff: result.diff ?? '', + }; + }, + enabled: !!projectPath && !!featureId, + staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote?: boolean; + lastCommit?: string; + upstream?: string; +} + +interface BranchesResult { + branches: BranchInfo[]; + aheadCount: number; + behindCount: number; + hasRemoteBranch: boolean; + isGitRepo: boolean; + hasCommits: boolean; +} + +/** + * Fetch available branches for a worktree + * + * @param worktreePath - Path to the worktree + * @param includeRemote - Whether to include remote branches + * @returns Query result with branches, ahead/behind counts, and git repo status + */ +export function useWorktreeBranches(worktreePath: string | undefined, includeRemote = false) { + return useQuery({ + // Include includeRemote in query key so different configurations have separate caches + queryKey: queryKeys.worktrees.branches(worktreePath ?? '', includeRemote), + queryFn: async (): Promise => { + if (!worktreePath) throw new Error('No worktree path'); + const api = getElectronAPI(); + const result = await api.worktree.listBranches(worktreePath, includeRemote); + + // Handle special git status codes + if (result.code === 'NOT_GIT_REPO') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + hasRemoteBranch: false, + isGitRepo: false, + hasCommits: false, + }; + } + if (result.code === 'NO_COMMITS') { + return { + branches: [], + aheadCount: 0, + behindCount: 0, + hasRemoteBranch: false, + isGitRepo: true, + hasCommits: false, + }; + } + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch branches'); + } + + return { + branches: result.result?.branches ?? [], + aheadCount: result.result?.aheadCount ?? 0, + behindCount: result.result?.behindCount ?? 0, + hasRemoteBranch: result.result?.hasRemoteBranch ?? false, + isGitRepo: true, + hasCommits: true, + }; + }, + enabled: !!worktreePath, + staleTime: STALE_TIMES.WORKTREES, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch init script for a project + * + * @param projectPath - Path to the project + * @returns Query result with init script content + */ +export function useWorktreeInitScript(projectPath: string | undefined) { + return useQuery({ + queryKey: queryKeys.worktrees.initScript(projectPath ?? ''), + queryFn: async () => { + if (!projectPath) throw new Error('No project path'); + const api = getElectronAPI(); + const result = await api.worktree.getInitScript(projectPath); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch init script'); + } + return { + exists: result.exists ?? false, + content: result.content ?? '', + }; + }, + enabled: !!projectPath, + staleTime: STALE_TIMES.SETTINGS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} + +/** + * Fetch available editors + * + * @returns Query result with available editors + */ +export function useAvailableEditors() { + return useQuery({ + queryKey: queryKeys.worktrees.editors(), + queryFn: async () => { + const api = getElectronAPI(); + const result = await api.worktree.getAvailableEditors(); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch editors'); + } + return result.editors ?? []; + }, + staleTime: STALE_TIMES.CLI_STATUS, + refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS, + refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index b62f6fa4..43af07a0 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -93,10 +93,12 @@ export function useAutoMode(worktree?: WorktreeInfo) { })) ); - // Derive branchName from worktree: main worktree uses null, feature worktrees use their branch + // Derive branchName from worktree: + // If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch) + // If not provided, default to null (main worktree default) const branchName = useMemo(() => { if (!worktree) return null; - return worktree.isMain ? null : worktree.branch; + return worktree.isMain ? null : worktree.branch || null; }, [worktree]); // Helper to look up project ID from path @@ -155,7 +157,13 @@ export function useAutoMode(worktree?: WorktreeInfo) { logger.info( `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` ); - setAutoModeRunning(currentProject.id, branchName, backendIsRunning); + setAutoModeRunning( + currentProject.id, + branchName, + backendIsRunning, + result.maxConcurrency, + result.runningFeatures + ); setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); } } @@ -165,7 +173,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { }; syncWithBackend(); - }, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]); + }, [currentProject, branchName, setAutoModeRunning]); // Handle auto mode events - listen globally for all projects/worktrees useEffect(() => { @@ -215,6 +223,26 @@ export function useAutoMode(worktree?: WorktreeInfo) { } break; + case 'auto_mode_resuming_features': + // Backend is resuming features from saved state + if (eventProjectId && 'features' in event && Array.isArray(event.features)) { + logger.info(`[AutoMode] Resuming ${event.features.length} feature(s) from saved state`); + // Use per-feature branchName if available, fallback to event-level branchName + event.features.forEach((feature: { id: string; branchName?: string | null }) => { + const featureBranchName = feature.branchName ?? eventBranchName; + addRunningTask(eventProjectId, featureBranchName, feature.id); + }); + } else if (eventProjectId && 'featureIds' in event && Array.isArray(event.featureIds)) { + // Fallback for older event format without per-feature branchName + logger.info( + `[AutoMode] Resuming ${event.featureIds.length} feature(s) from saved state (legacy format)` + ); + event.featureIds.forEach((featureId: string) => { + addRunningTask(eventProjectId, eventBranchName, featureId); + }); + } + break; + case 'auto_mode_stopped': // Backend stopped auto loop - update UI state { @@ -484,11 +512,16 @@ export function useAutoMode(worktree?: WorktreeInfo) { logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`); // Optimistically update UI state (backend will confirm via event) + const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName); setAutoModeSessionForWorktree(currentProject.path, branchName, true); - setAutoModeRunning(currentProject.id, branchName, true); + setAutoModeRunning(currentProject.id, branchName, true, currentMaxConcurrency); - // Call backend to start the auto loop (backend uses stored concurrency) - const result = await api.autoMode.start(currentProject.path, branchName); + // Call backend to start the auto loop (pass current max concurrency) + const result = await api.autoMode.start( + currentProject.path, + branchName, + currentMaxConcurrency + ); if (!result.success) { // Revert UI state on failure diff --git a/apps/ui/src/hooks/use-board-background-settings.ts b/apps/ui/src/hooks/use-board-background-settings.ts index fdb09b36..33618941 100644 --- a/apps/ui/src/hooks/use-board-background-settings.ts +++ b/apps/ui/src/hooks/use-board-background-settings.ts @@ -1,36 +1,26 @@ import { useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; -import { toast } from 'sonner'; - -const logger = createLogger('BoardBackground'); +import { useUpdateProjectSettings } from '@/hooks/mutations'; /** - * Hook for managing board background settings with automatic persistence to server + * Hook for managing board background settings with automatic persistence to server. + * Uses React Query mutation for server persistence with automatic error handling. */ export function useBoardBackgroundSettings() { const store = useAppStore(); - const httpClient = getHttpApiClient(); + + // Get the mutation without a fixed project path - we'll pass it with each call + const updateProjectSettings = useUpdateProjectSettings(); // Helper to persist settings to server const persistSettings = useCallback( - async (projectPath: string, settingsToUpdate: Record) => { - try { - const result = await httpClient.settings.updateProject(projectPath, { - boardBackground: settingsToUpdate, - }); - - if (!result.success) { - logger.error('Failed to persist settings:', result.error); - toast.error('Failed to save settings'); - } - } catch (error) { - logger.error('Failed to persist settings:', error); - toast.error('Failed to save settings'); - } + (projectPath: string, settingsToUpdate: Record) => { + updateProjectSettings.mutate({ + projectPath, + settings: { boardBackground: settingsToUpdate }, + }); }, - [httpClient] + [updateProjectSettings] ); // Get current background settings for a project diff --git a/apps/ui/src/hooks/use-guided-prompts.ts b/apps/ui/src/hooks/use-guided-prompts.ts index e192d6b3..e7d18e84 100644 --- a/apps/ui/src/hooks/use-guided-prompts.ts +++ b/apps/ui/src/hooks/use-guided-prompts.ts @@ -2,12 +2,12 @@ * Hook for fetching guided prompts from the backend API * * This hook provides the single source of truth for guided prompts, - * fetched from the backend /api/ideation/prompts endpoint. + * with caching via React Query. */ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types'; -import { getElectronAPI } from '@/lib/electron'; +import { useIdeationPrompts } from '@/hooks/queries'; interface UseGuidedPromptsReturn { prompts: IdeationPrompt[]; @@ -21,36 +21,10 @@ interface UseGuidedPromptsReturn { } export function useGuidedPrompts(): UseGuidedPromptsReturn { - const [prompts, setPrompts] = useState([]); - const [categories, setCategories] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const { data, isLoading, error, refetch } = useIdeationPrompts(); - const fetchPrompts = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const api = getElectronAPI(); - const result = await api.ideation?.getPrompts(); - - if (result?.success) { - setPrompts(result.prompts || []); - setCategories(result.categories || []); - } else { - setError(result?.error || 'Failed to fetch prompts'); - } - } catch (err) { - console.error('Failed to fetch guided prompts:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch prompts'); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - fetchPrompts(); - }, [fetchPrompts]); + const prompts = data?.prompts ?? []; + const categories = data?.categories ?? []; const getPromptsByCategory = useCallback( (category: IdeaCategory): IdeationPrompt[] => { @@ -73,12 +47,23 @@ export function useGuidedPrompts(): UseGuidedPromptsReturn { [categories] ); + // Convert async refetch to match the expected interface + const handleRefetch = useCallback(async () => { + await refetch(); + }, [refetch]); + + // Convert error to string for backward compatibility + const errorMessage = useMemo(() => { + if (!error) return null; + return error instanceof Error ? error.message : String(error); + }, [error]); + return { prompts, categories, isLoading, - error, - refetch: fetchPrompts, + error: errorMessage, + refetch: handleRefetch, getPromptsByCategory, getPromptById, getCategoryById, diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index c5d7c633..e672d411 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -1,11 +1,13 @@ import { useEffect, useRef } from 'react'; import { useAppStore } from '@/store/app-store'; -import { getHttpApiClient } from '@/lib/http-api-client'; +import { useProjectSettings } from '@/hooks/queries'; /** * Hook that loads project settings from the server when the current project changes. * This ensures that settings like board backgrounds are properly restored when * switching between projects or restarting the app. + * + * Uses React Query for data fetching with automatic caching. */ export function useProjectSettingsLoader() { const currentProject = useAppStore((state) => state.currentProject); @@ -25,138 +27,131 @@ export function useProjectSettingsLoader() { ); const setCurrentProject = useAppStore((state) => state.setCurrentProject); - const loadingRef = useRef(null); - const currentProjectRef = useRef(null); + const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null); + // Fetch project settings with React Query + const { data: settings, dataUpdatedAt } = useProjectSettings(currentProject?.path); + + // Apply settings when data changes useEffect(() => { - currentProjectRef.current = currentProject?.path ?? null; - - if (!currentProject?.path) { + if (!currentProject?.path || !settings) { return; } - // Prevent loading the same project multiple times - if (loadingRef.current === currentProject.path) { + // Prevent applying the same settings multiple times + if ( + appliedProjectRef.current?.path === currentProject.path && + appliedProjectRef.current?.dataUpdatedAt === dataUpdatedAt + ) { return; } - loadingRef.current = currentProject.path; - const requestedProjectPath = currentProject.path; + appliedProjectRef.current = { path: currentProject.path, dataUpdatedAt }; + const projectPath = currentProject.path; - const loadProjectSettings = async () => { - try { - const httpClient = getHttpApiClient(); - const result = await httpClient.settings.getProject(requestedProjectPath); + const bg = settings.boardBackground; - // Race condition protection: ignore stale results if project changed - if (currentProjectRef.current !== requestedProjectPath) { - return; - } + // Apply boardBackground if present + if (bg?.imagePath) { + setBoardBackground(projectPath, bg.imagePath); + } - if (result.success && result.settings) { - const bg = result.settings.boardBackground; + // Settings map for cleaner iteration + const settingsMap = { + cardOpacity: setCardOpacity, + columnOpacity: setColumnOpacity, + columnBorderEnabled: setColumnBorderEnabled, + cardGlassmorphism: setCardGlassmorphism, + cardBorderEnabled: setCardBorderEnabled, + cardBorderOpacity: setCardBorderOpacity, + hideScrollbar: setHideScrollbar, + } as const; - // Apply boardBackground if present - if (bg?.imagePath) { - setBoardBackground(requestedProjectPath, bg.imagePath); - } - - // Settings map for cleaner iteration - const settingsMap = { - cardOpacity: setCardOpacity, - columnOpacity: setColumnOpacity, - columnBorderEnabled: setColumnBorderEnabled, - cardGlassmorphism: setCardGlassmorphism, - cardBorderEnabled: setCardBorderEnabled, - cardBorderOpacity: setCardBorderOpacity, - hideScrollbar: setHideScrollbar, - } as const; - - // Apply all settings that are defined - for (const [key, setter] of Object.entries(settingsMap)) { - const value = bg?.[key as keyof typeof bg]; - if (value !== undefined) { - (setter as (path: string, val: typeof value) => void)(requestedProjectPath, value); - } - } - - // Apply worktreePanelVisible if present - if (result.settings.worktreePanelVisible !== undefined) { - setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible); - } - - // Apply showInitScriptIndicator if present - if (result.settings.showInitScriptIndicator !== undefined) { - setShowInitScriptIndicator( - requestedProjectPath, - result.settings.showInitScriptIndicator - ); - } - - // Apply defaultDeleteBranch if present - if (result.settings.defaultDeleteBranchWithWorktree !== undefined) { - setDefaultDeleteBranch( - requestedProjectPath, - result.settings.defaultDeleteBranchWithWorktree - ); - } - - // Apply autoDismissInitScriptIndicator if present - if (result.settings.autoDismissInitScriptIndicator !== undefined) { - setAutoDismissInitScriptIndicator( - requestedProjectPath, - result.settings.autoDismissInitScriptIndicator - ); - } - - // Apply activeClaudeApiProfileId and phaseModelOverrides if present - // These are stored directly on the project, so we need to update both - // currentProject AND the projects array to keep them in sync - // Type assertion needed because API returns Record - const settingsWithProfile = result.settings as Record; - const activeClaudeApiProfileId = settingsWithProfile.activeClaudeApiProfileId as - | string - | null - | undefined; - const phaseModelOverrides = settingsWithProfile.phaseModelOverrides as - | import('@automaker/types').PhaseModelConfig - | undefined; - - // Check if we need to update the project - const storeState = useAppStore.getState(); - const updatedProject = storeState.currentProject; - if (updatedProject && updatedProject.path === requestedProjectPath) { - const needsUpdate = - (activeClaudeApiProfileId !== undefined && - updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) || - (phaseModelOverrides !== undefined && - JSON.stringify(updatedProject.phaseModelOverrides) !== - JSON.stringify(phaseModelOverrides)); - - if (needsUpdate) { - const updatedProjectData = { - ...updatedProject, - ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }), - ...(phaseModelOverrides !== undefined && { phaseModelOverrides }), - }; - - // Update currentProject - setCurrentProject(updatedProjectData); - - // Also update the project in the projects array to keep them in sync - const updatedProjects = storeState.projects.map((p) => - p.id === updatedProject.id ? updatedProjectData : p - ); - useAppStore.setState({ projects: updatedProjects }); - } - } - } - } catch (error) { - console.error('Failed to load project settings:', error); - // Don't show error toast - just log it + // Apply all settings that are defined + for (const [key, setter] of Object.entries(settingsMap)) { + const value = bg?.[key as keyof typeof bg]; + if (value !== undefined) { + (setter as (path: string, val: typeof value) => void)(projectPath, value); } - }; + } - loadProjectSettings(); - }, [currentProject?.path]); + // Apply worktreePanelVisible if present + if (settings.worktreePanelVisible !== undefined) { + setWorktreePanelVisible(projectPath, settings.worktreePanelVisible); + } + + // Apply showInitScriptIndicator if present + if (settings.showInitScriptIndicator !== undefined) { + setShowInitScriptIndicator(projectPath, settings.showInitScriptIndicator); + } + + // Apply defaultDeleteBranchWithWorktree if present + if (settings.defaultDeleteBranchWithWorktree !== undefined) { + setDefaultDeleteBranch(projectPath, settings.defaultDeleteBranchWithWorktree); + } + + // Apply autoDismissInitScriptIndicator if present + if (settings.autoDismissInitScriptIndicator !== undefined) { + setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator); + } + + // Apply activeClaudeApiProfileId and phaseModelOverrides if present + // These are stored directly on the project, so we need to update both + // currentProject AND the projects array to keep them in sync + // Type assertion needed because API returns Record + const settingsWithExtras = settings as Record; + const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as + | string + | null + | undefined; + const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as + | import('@automaker/types').PhaseModelConfig + | undefined; + + // Check if we need to update the project + const storeState = useAppStore.getState(); + const updatedProject = storeState.currentProject; + if (updatedProject && updatedProject.path === projectPath) { + const needsUpdate = + (activeClaudeApiProfileId !== undefined && + updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) || + (phaseModelOverrides !== undefined && + JSON.stringify(updatedProject.phaseModelOverrides) !== + JSON.stringify(phaseModelOverrides)); + + if (needsUpdate) { + const updatedProjectData = { + ...updatedProject, + ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }), + ...(phaseModelOverrides !== undefined && { phaseModelOverrides }), + }; + + // Update currentProject + setCurrentProject(updatedProjectData); + + // Also update the project in the projects array to keep them in sync + const updatedProjects = storeState.projects.map((p) => + p.id === updatedProject.id ? updatedProjectData : p + ); + useAppStore.setState({ projects: updatedProjects }); + } + } + }, [ + currentProject?.path, + settings, + dataUpdatedAt, + setBoardBackground, + setCardOpacity, + setColumnOpacity, + setColumnBorderEnabled, + setCardGlassmorphism, + setCardBorderEnabled, + setCardBorderOpacity, + setHideScrollbar, + setWorktreePanelVisible, + setShowInitScriptIndicator, + setDefaultDeleteBranch, + setAutoDismissInitScriptIndicator, + setCurrentProject, + ]); } diff --git a/apps/ui/src/hooks/use-query-invalidation.ts b/apps/ui/src/hooks/use-query-invalidation.ts new file mode 100644 index 00000000..eb0bfb4d --- /dev/null +++ b/apps/ui/src/hooks/use-query-invalidation.ts @@ -0,0 +1,234 @@ +/** + * Query Invalidation Hooks + * + * These hooks connect WebSocket events to React Query cache invalidation, + * ensuring the UI stays in sync with server-side changes without manual refetching. + */ + +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; +import type { AutoModeEvent, SpecRegenerationEvent } from '@/types/electron'; +import type { IssueValidationEvent } from '@automaker/types'; + +/** + * Invalidate queries based on auto mode events + * + * This hook subscribes to auto mode events (feature start, complete, error, etc.) + * and invalidates relevant queries to keep the UI in sync. + * + * @param projectPath - Current project path + * + * @example + * ```tsx + * function BoardView() { + * const projectPath = useAppStore(s => s.currentProject?.path); + * useAutoModeQueryInvalidation(projectPath); + * // ... + * } + * ``` + */ +export function useAutoModeQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + // Invalidate features when agent completes, errors, or receives plan approval + if ( + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'plan_approval_required' || + event.type === 'plan_approved' || + event.type === 'plan_rejected' || + event.type === 'pipeline_step_complete' + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + } + + // Invalidate running agents on any status change + if ( + event.type === 'auto_mode_feature_start' || + event.type === 'auto_mode_feature_complete' || + event.type === 'auto_mode_error' || + event.type === 'auto_mode_resuming_features' + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.runningAgents.all(), + }); + } + + // Invalidate specific feature when it starts or has phase changes + if ( + (event.type === 'auto_mode_feature_start' || + event.type === 'auto_mode_phase' || + event.type === 'auto_mode_phase_complete' || + event.type === 'pipeline_step_started') && + 'featureId' in event + ) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.single(projectPath, event.featureId), + }); + } + + // Invalidate agent output during progress updates + if (event.type === 'auto_mode_progress' && 'featureId' in event) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.agentOutput(projectPath, event.featureId), + }); + } + + // Invalidate worktree queries when feature completes (may have created worktree) + if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) { + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.single(projectPath, event.featureId), + }); + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate queries based on spec regeneration events + * + * @param projectPath - Current project path + */ +export function useSpecRegenerationQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => { + // Only handle events for the current project + if (event.projectPath !== projectPath) return; + + if (event.type === 'spec_regeneration_complete') { + // Invalidate features as new ones may have been generated + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(projectPath), + }); + + // Invalidate spec regeneration status + queryClient.invalidateQueries({ + queryKey: queryKeys.specRegeneration.status(projectPath), + }); + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate queries based on GitHub validation events + * + * @param projectPath - Current project path + */ +export function useGitHubValidationQueryInvalidation(projectPath: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!projectPath) return; + + const api = getElectronAPI(); + + // Check if GitHub API is available before subscribing + if (!api.github?.onValidationEvent) { + return; + } + + const unsubscribe = api.github.onValidationEvent((event: IssueValidationEvent) => { + if (event.type === 'validation_complete' || event.type === 'validation_error') { + // Invalidate all validations for this project + queryClient.invalidateQueries({ + queryKey: queryKeys.github.validations(projectPath), + }); + + // Also invalidate specific issue validation if we have the issue number + if ('issueNumber' in event && event.issueNumber) { + queryClient.invalidateQueries({ + queryKey: queryKeys.github.validation(projectPath, event.issueNumber), + }); + } + } + }); + + return unsubscribe; + }, [projectPath, queryClient]); +} + +/** + * Invalidate session queries based on agent stream events + * + * @param sessionId - Current session ID + */ +export function useSessionQueryInvalidation(sessionId: string | undefined) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!sessionId) return; + + const api = getElectronAPI(); + const unsubscribe = api.agent.onStream((event) => { + // Only handle events for the current session + if ('sessionId' in event && event.sessionId !== sessionId) return; + + // Invalidate session history when a message is complete + if (event.type === 'complete' || event.type === 'message') { + queryClient.invalidateQueries({ + queryKey: queryKeys.sessions.history(sessionId), + }); + } + + // Invalidate sessions list when any session changes + if (event.type === 'complete') { + queryClient.invalidateQueries({ + queryKey: queryKeys.sessions.all(), + }); + } + }); + + return unsubscribe; + }, [sessionId, queryClient]); +} + +/** + * Combined hook that sets up all query invalidation subscriptions + * + * Use this hook at the app root or in a layout component to ensure + * all WebSocket events properly invalidate React Query caches. + * + * @param projectPath - Current project path + * @param sessionId - Current session ID (optional) + * + * @example + * ```tsx + * function AppLayout() { + * const projectPath = useAppStore(s => s.currentProject?.path); + * const sessionId = useAppStore(s => s.currentSessionId); + * useQueryInvalidation(projectPath, sessionId); + * // ... + * } + * ``` + */ +export function useQueryInvalidation( + projectPath: string | undefined, + sessionId?: string | undefined +) { + useAutoModeQueryInvalidation(projectPath); + useSpecRegenerationQueryInvalidation(projectPath); + useGitHubValidationQueryInvalidation(projectPath); + useSessionQueryInvalidation(sessionId); +} diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 439f3909..ff24d42f 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -212,6 +212,8 @@ export function parseLocalStorageSettings(): Partial | null { claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], activeClaudeApiProfileId: (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, + // Event hooks + eventHooks: state.eventHooks as GlobalSettings['eventHooks'], }; } catch (error) { logger.error('Failed to parse localStorage settings:', error); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cf5ee486..4f311025 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1566,15 +1566,18 @@ function createMockWorktreeAPI(): WorktreeAPI { projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: object ) => { + const target = targetBranch || 'main'; console.log('[Mock] Merging feature:', { projectPath, branchName, worktreePath, + targetBranch: target, options, }); - return { success: true, mergedBranch: branchName }; + return { success: true, mergedBranch: branchName, targetBranch: target }; }, getInfo: async (projectPath: string, featureId: string) => { @@ -1684,14 +1687,15 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, - push: async (worktreePath: string, force?: boolean) => { - console.log('[Mock] Pushing worktree:', { worktreePath, force }); + push: async (worktreePath: string, force?: boolean, remote?: string) => { + const targetRemote = remote || 'origin'; + console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote }); return { success: true, result: { branch: 'feature-branch', pushed: true, - message: 'Successfully pushed to origin/feature-branch', + message: `Successfully pushed to ${targetRemote}/feature-branch`, }, }; }, @@ -1777,6 +1781,7 @@ function createMockWorktreeAPI(): WorktreeAPI { ], aheadCount: 2, behindCount: 0, + hasRemoteBranch: true, }, }; }, @@ -1793,6 +1798,26 @@ function createMockWorktreeAPI(): WorktreeAPI { }; }, + listRemotes: async (worktreePath: string) => { + console.log('[Mock] Listing remotes for:', worktreePath); + return { + success: true, + result: { + remotes: [ + { + name: 'origin', + url: 'git@github.com:example/repo.git', + branches: [ + { name: 'main', fullRef: 'origin/main' }, + { name: 'develop', fullRef: 'origin/develop' }, + { name: 'feature/example', fullRef: 'origin/feature/example' }, + ], + }, + ], + }, + }; + }, + openInEditor: async (worktreePath: string, editorCommand?: string) => { const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity'; const ANTIGRAVITY_LEGACY_COMMAND = 'agy'; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index e6292bd7..dbfddc4c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1763,8 +1763,16 @@ export class HttpApiClient implements ElectronAPI { projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: object - ) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }), + ) => + this.post('/api/worktree/merge', { + projectPath, + branchName, + worktreePath, + targetBranch, + options, + }), getInfo: (projectPath: string, featureId: string) => this.post('/api/worktree/info', { projectPath, featureId }), getStatus: (projectPath: string, featureId: string) => @@ -1788,8 +1796,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/commit', { worktreePath, message }), generateCommitMessage: (worktreePath: string) => this.post('/api/worktree/generate-commit-message', { worktreePath }), - push: (worktreePath: string, force?: boolean) => - this.post('/api/worktree/push', { worktreePath, force }), + push: (worktreePath: string, force?: boolean, remote?: string) => + this.post('/api/worktree/push', { worktreePath, force, remote }), createPR: (worktreePath: string, options?: any) => this.post('/api/worktree/create-pr', { worktreePath, ...options }), getDiffs: (projectPath: string, featureId: string) => @@ -1807,6 +1815,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), + listRemotes: (worktreePath: string) => + this.post('/api/worktree/list-remotes', { worktreePath }), openInEditor: (worktreePath: string, editorCommand?: string) => this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), getDefaultEditor: () => this.get('/api/worktree/default-editor'), diff --git a/apps/ui/src/lib/query-client.ts b/apps/ui/src/lib/query-client.ts new file mode 100644 index 00000000..82344f2a --- /dev/null +++ b/apps/ui/src/lib/query-client.ts @@ -0,0 +1,138 @@ +/** + * React Query Client Configuration + * + * Central configuration for TanStack React Query. + * Provides default options for queries and mutations including + * caching, retries, and error handling. + */ + +import { QueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { createLogger } from '@automaker/utils/logger'; +import { isConnectionError, handleServerOffline } from './http-api-client'; + +const logger = createLogger('QueryClient'); + +/** + * Default stale times for different data types + */ +export const STALE_TIMES = { + /** Features change frequently during auto-mode */ + FEATURES: 60 * 1000, // 1 minute + /** GitHub data is relatively stable */ + GITHUB: 2 * 60 * 1000, // 2 minutes + /** Running agents state changes very frequently */ + RUNNING_AGENTS: 5 * 1000, // 5 seconds + /** Agent output changes during streaming */ + AGENT_OUTPUT: 5 * 1000, // 5 seconds + /** Usage data with polling */ + USAGE: 30 * 1000, // 30 seconds + /** Models rarely change */ + MODELS: 5 * 60 * 1000, // 5 minutes + /** CLI status rarely changes */ + CLI_STATUS: 5 * 60 * 1000, // 5 minutes + /** Settings are relatively stable */ + SETTINGS: 2 * 60 * 1000, // 2 minutes + /** Worktrees change during feature development */ + WORKTREES: 30 * 1000, // 30 seconds + /** Sessions rarely change */ + SESSIONS: 2 * 60 * 1000, // 2 minutes + /** Default for unspecified queries */ + DEFAULT: 30 * 1000, // 30 seconds +} as const; + +/** + * Default garbage collection times (gcTime, formerly cacheTime) + */ +export const GC_TIMES = { + /** Default garbage collection time */ + DEFAULT: 5 * 60 * 1000, // 5 minutes + /** Extended for expensive queries */ + EXTENDED: 10 * 60 * 1000, // 10 minutes +} as const; + +/** + * Global error handler for queries + */ +const handleQueryError = (error: Error) => { + logger.error('Query error:', error); + + // Check for connection errors (server offline) + if (isConnectionError(error)) { + handleServerOffline(); + return; + } + + // Don't toast for auth errors - those are handled by http-api-client + if (error.message === 'Unauthorized') { + return; + } +}; + +/** + * Global error handler for mutations + */ +const handleMutationError = (error: Error) => { + logger.error('Mutation error:', error); + + // Check for connection errors + if (isConnectionError(error)) { + handleServerOffline(); + return; + } + + // Don't toast for auth errors + if (error.message === 'Unauthorized') { + return; + } + + // Show error toast for other errors + toast.error('Operation failed', { + description: error.message || 'An unexpected error occurred', + }); +}; + +/** + * Create and configure the QueryClient singleton + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE_TIMES.DEFAULT, + gcTime: GC_TIMES.DEFAULT, + retry: (failureCount, error) => { + // Don't retry on auth errors + if (error instanceof Error && error.message === 'Unauthorized') { + return false; + } + // Don't retry on connection errors (server offline) + if (isConnectionError(error)) { + return false; + } + // Retry up to 2 times for other errors + return failureCount < 2; + }, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + // Don't refetch on mount if data is fresh + refetchOnMount: true, + }, + mutations: { + onError: handleMutationError, + retry: false, // Don't auto-retry mutations + }, + }, +}); + +/** + * Set up global query error handling + * This catches errors that aren't handled by individual queries + */ +queryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated' && event.query.state.status === 'error') { + const error = event.query.state.error; + if (error instanceof Error) { + handleQueryError(error); + } + } +}); diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts new file mode 100644 index 00000000..feb69c65 --- /dev/null +++ b/apps/ui/src/lib/query-keys.ts @@ -0,0 +1,282 @@ +/** + * Query Keys Factory + * + * Centralized query key definitions for React Query. + * Following the factory pattern for type-safe, consistent query keys. + * + * @see https://tkdodo.eu/blog/effective-react-query-keys + */ + +/** + * Query keys for all API endpoints + * + * Structure follows the pattern: + * - ['entity'] for listing/global + * - ['entity', id] for single item + * - ['entity', id, 'sub-resource'] for nested resources + */ +export const queryKeys = { + // ============================================ + // Features + // ============================================ + features: { + /** All features for a project */ + all: (projectPath: string) => ['features', projectPath] as const, + /** Single feature */ + single: (projectPath: string, featureId: string) => + ['features', projectPath, featureId] as const, + /** Agent output for a feature */ + agentOutput: (projectPath: string, featureId: string) => + ['features', projectPath, featureId, 'output'] as const, + }, + + // ============================================ + // Worktrees + // ============================================ + worktrees: { + /** All worktrees for a project */ + all: (projectPath: string) => ['worktrees', projectPath] as const, + /** Single worktree info */ + single: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId] as const, + /** Branches for a worktree */ + branches: (worktreePath: string, includeRemote = false) => + ['worktrees', 'branches', worktreePath, { includeRemote }] as const, + /** Worktree status */ + status: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId, 'status'] as const, + /** Worktree diffs */ + diffs: (projectPath: string, featureId: string) => + ['worktrees', projectPath, featureId, 'diffs'] as const, + /** Init script for a project */ + initScript: (projectPath: string) => ['worktrees', projectPath, 'init-script'] as const, + /** Available editors */ + editors: () => ['worktrees', 'editors'] as const, + }, + + // ============================================ + // GitHub + // ============================================ + github: { + /** GitHub issues for a project */ + issues: (projectPath: string) => ['github', 'issues', projectPath] as const, + /** GitHub PRs for a project */ + prs: (projectPath: string) => ['github', 'prs', projectPath] as const, + /** GitHub validations for a project */ + validations: (projectPath: string) => ['github', 'validations', projectPath] as const, + /** Single validation */ + validation: (projectPath: string, issueNumber: number) => + ['github', 'validations', projectPath, issueNumber] as const, + /** Issue comments */ + issueComments: (projectPath: string, issueNumber: number) => + ['github', 'issues', projectPath, issueNumber, 'comments'] as const, + /** Remote info */ + remote: (projectPath: string) => ['github', 'remote', projectPath] as const, + }, + + // ============================================ + // Settings + // ============================================ + settings: { + /** Global settings */ + global: () => ['settings', 'global'] as const, + /** Project-specific settings */ + project: (projectPath: string) => ['settings', 'project', projectPath] as const, + /** Settings status */ + status: () => ['settings', 'status'] as const, + /** Credentials (API keys) */ + credentials: () => ['settings', 'credentials'] as const, + /** Discovered agents */ + agents: (projectPath: string, sources?: Array<'user' | 'project'>) => + ['settings', 'agents', projectPath, sources ?? []] as const, + }, + + // ============================================ + // Usage & Billing + // ============================================ + usage: { + /** Claude API usage */ + claude: () => ['usage', 'claude'] as const, + /** Codex API usage */ + codex: () => ['usage', 'codex'] as const, + }, + + // ============================================ + // Models + // ============================================ + models: { + /** Available models */ + available: () => ['models', 'available'] as const, + /** Codex models */ + codex: () => ['models', 'codex'] as const, + /** OpenCode models */ + opencode: () => ['models', 'opencode'] as const, + /** OpenCode providers */ + opencodeProviders: () => ['models', 'opencode', 'providers'] as const, + /** Provider status */ + providers: () => ['models', 'providers'] as const, + }, + + // ============================================ + // Sessions + // ============================================ + sessions: { + /** All sessions */ + all: (includeArchived?: boolean) => ['sessions', { includeArchived }] as const, + /** Session history */ + history: (sessionId: string) => ['sessions', sessionId, 'history'] as const, + /** Session queue */ + queue: (sessionId: string) => ['sessions', sessionId, 'queue'] as const, + }, + + // ============================================ + // Running Agents + // ============================================ + runningAgents: { + /** All running agents */ + all: () => ['runningAgents'] as const, + }, + + // ============================================ + // Auto Mode + // ============================================ + autoMode: { + /** Auto mode status */ + status: (projectPath?: string) => ['autoMode', 'status', projectPath] as const, + /** Context exists check */ + contextExists: (projectPath: string, featureId: string) => + ['autoMode', projectPath, featureId, 'context'] as const, + }, + + // ============================================ + // Ideation + // ============================================ + ideation: { + /** Ideation prompts */ + prompts: () => ['ideation', 'prompts'] as const, + /** Ideas for a project */ + ideas: (projectPath: string) => ['ideation', 'ideas', projectPath] as const, + /** Single idea */ + idea: (projectPath: string, ideaId: string) => + ['ideation', 'ideas', projectPath, ideaId] as const, + /** Session */ + session: (projectPath: string, sessionId: string) => + ['ideation', 'session', projectPath, sessionId] as const, + }, + + // ============================================ + // CLI Status + // ============================================ + cli: { + /** Claude CLI status */ + claude: () => ['cli', 'claude'] as const, + /** Cursor CLI status */ + cursor: () => ['cli', 'cursor'] as const, + /** Codex CLI status */ + codex: () => ['cli', 'codex'] as const, + /** OpenCode CLI status */ + opencode: () => ['cli', 'opencode'] as const, + /** GitHub CLI status */ + github: () => ['cli', 'github'] as const, + /** API keys status */ + apiKeys: () => ['cli', 'apiKeys'] as const, + /** Platform info */ + platform: () => ['cli', 'platform'] as const, + }, + + // ============================================ + // Cursor Permissions + // ============================================ + cursorPermissions: { + /** Cursor permissions for a project */ + permissions: (projectPath?: string) => ['cursorPermissions', projectPath] as const, + }, + + // ============================================ + // Workspace + // ============================================ + workspace: { + /** Workspace config */ + config: () => ['workspace', 'config'] as const, + /** Workspace directories */ + directories: () => ['workspace', 'directories'] as const, + }, + + // ============================================ + // MCP (Model Context Protocol) + // ============================================ + mcp: { + /** MCP server tools */ + tools: (serverId: string) => ['mcp', 'tools', serverId] as const, + }, + + // ============================================ + // Pipeline + // ============================================ + pipeline: { + /** Pipeline config for a project */ + config: (projectPath: string) => ['pipeline', projectPath] as const, + }, + + // ============================================ + // Suggestions + // ============================================ + suggestions: { + /** Suggestions status */ + status: () => ['suggestions', 'status'] as const, + }, + + // ============================================ + // Spec Regeneration + // ============================================ + specRegeneration: { + /** Spec regeneration status */ + status: (projectPath?: string) => ['specRegeneration', 'status', projectPath] as const, + }, + + // ============================================ + // Spec + // ============================================ + spec: { + /** Spec file content */ + file: (projectPath: string) => ['spec', 'file', projectPath] as const, + }, + + // ============================================ + // Context + // ============================================ + context: { + /** File description */ + file: (filePath: string) => ['context', 'file', filePath] as const, + /** Image description */ + image: (imagePath: string) => ['context', 'image', imagePath] as const, + }, + + // ============================================ + // File System + // ============================================ + fs: { + /** Directory listing */ + readdir: (dirPath: string) => ['fs', 'readdir', dirPath] as const, + /** File existence */ + exists: (filePath: string) => ['fs', 'exists', filePath] as const, + /** File stats */ + stat: (filePath: string) => ['fs', 'stat', filePath] as const, + }, + + // ============================================ + // Git + // ============================================ + git: { + /** Git diffs for a project */ + diffs: (projectPath: string) => ['git', 'diffs', projectPath] as const, + /** File diff */ + fileDiff: (projectPath: string, filePath: string) => + ['git', 'diffs', projectPath, filePath] as const, + }, +} as const; + +/** + * Type helper to extract query key types + */ +export type QueryKeys = typeof queryKeys; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index e1a115d5..1660e048 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -1,5 +1,7 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; import { Sidebar } from '@/components/layout/sidebar'; import { ProjectSwitcher } from '@/components/layout/project-switcher'; @@ -27,6 +29,7 @@ import { signalMigrationComplete, performSettingsMigration, } from '@/hooks/use-settings-migration'; +import { queryClient } from '@/lib/query-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; @@ -37,6 +40,7 @@ import { useIsCompact } from '@/hooks/use-media-query'; import type { Project } from '@/lib/electron'; const logger = createLogger('RootLayout'); +const SHOW_QUERY_DEVTOOLS = import.meta.env.DEV; const SERVER_READY_MAX_ATTEMPTS = 8; const SERVER_READY_BACKOFF_BASE_MS = 250; const SERVER_READY_MAX_DELAY_MS = 1500; @@ -892,9 +896,14 @@ function RootLayoutContent() { function RootLayout() { return ( - - - + + + + + {SHOW_QUERY_DEVTOOLS ? ( + + ) : null} + ); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4b17aa04..63dd7960 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -1087,7 +1087,8 @@ export interface AppActions { projectId: string, branchName: string | null, running: boolean, - maxConcurrency?: number + maxConcurrency?: number, + runningTasks?: string[] ) => void; addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; @@ -2271,10 +2272,19 @@ export const useAppStore = create()((set, get) => ({ // Auto Mode actions (per-worktree) getWorktreeKey: (projectId, branchName) => { - return `${projectId}::${branchName ?? '__main__'}`; + // Normalize 'main' to null so it matches the main worktree key + // The backend sometimes sends 'main' while the UI uses null for the main worktree + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectId}::${normalizedBranch ?? '__main__'}`; }, - setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => { + setAutoModeRunning: ( + projectId: string, + branchName: string | null, + running: boolean, + maxConcurrency?: number, + runningTasks?: string[] + ) => { const worktreeKey = get().getWorktreeKey(projectId, branchName); const current = get().autoModeByWorktree; const worktreeState = current[worktreeKey] || { @@ -2291,6 +2301,7 @@ export const useAppStore = create()((set, get) => ({ isRunning: running, branchName, maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + runningTasks: runningTasks ?? worktreeState.runningTasks, }, }, }); diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index a8a6e53a..6e942b88 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -132,6 +132,7 @@ :root { /* Default to light mode */ --radius: 0.625rem; + --perf-contain-intrinsic-size: 500px; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); @@ -1120,3 +1121,9 @@ animation: none; } } + +.perf-contain { + contain: layout paint; + content-visibility: auto; + contain-intrinsic-size: auto var(--perf-contain-intrinsic-size); +} diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index e01f3588..f98f58a9 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -219,6 +219,7 @@ export type AutoModeEvent = type: 'pipeline_step_started'; featureId: string; projectPath?: string; + branchName?: string | null; stepId: string; stepName: string; stepIndex: number; @@ -228,6 +229,7 @@ export type AutoModeEvent = type: 'pipeline_step_complete'; featureId: string; projectPath?: string; + branchName?: string | null; stepId: string; stepName: string; stepIndex: number; @@ -247,6 +249,7 @@ export type AutoModeEvent = featureId: string; projectId?: string; projectPath?: string; + branchName?: string | null; phase: 'planning' | 'action' | 'verification'; message: string; } @@ -254,6 +257,7 @@ export type AutoModeEvent = type: 'auto_mode_ultrathink_preparation'; featureId: string; projectPath?: string; + branchName?: string | null; warnings: string[]; recommendations: string[]; estimatedCost?: number; @@ -263,6 +267,7 @@ export type AutoModeEvent = type: 'plan_approval_required'; featureId: string; projectPath?: string; + branchName?: string | null; planContent: string; planningMode: 'lite' | 'spec' | 'full'; planVersion?: number; @@ -271,6 +276,7 @@ export type AutoModeEvent = type: 'plan_auto_approved'; featureId: string; projectPath?: string; + branchName?: string | null; planContent: string; planningMode: 'lite' | 'spec' | 'full'; } @@ -278,6 +284,7 @@ export type AutoModeEvent = type: 'plan_approved'; featureId: string; projectPath?: string; + branchName?: string | null; hasEdits: boolean; planVersion?: number; } @@ -285,12 +292,14 @@ export type AutoModeEvent = type: 'plan_rejected'; featureId: string; projectPath?: string; + branchName?: string | null; feedback?: string; } | { type: 'plan_revision_requested'; featureId: string; projectPath?: string; + branchName?: string | null; feedback?: string; hasEdits?: boolean; planVersion?: number; @@ -298,6 +307,7 @@ export type AutoModeEvent = | { type: 'planning_started'; featureId: string; + branchName?: string | null; mode: 'lite' | 'spec' | 'full'; message: string; } @@ -718,18 +728,25 @@ export interface FileDiffResult { } export interface WorktreeAPI { - // Merge worktree branch into main and clean up + // Merge worktree branch into a target branch (defaults to 'main') and optionally clean up mergeFeature: ( projectPath: string, branchName: string, worktreePath: string, + targetBranch?: string, options?: { squash?: boolean; message?: string; + deleteWorktreeAndBranch?: boolean; } ) => Promise<{ success: boolean; mergedBranch?: string; + targetBranch?: string; + deleted?: { + worktreeDeleted: boolean; + branchDeleted: boolean; + }; error?: string; }>; @@ -839,7 +856,8 @@ export interface WorktreeAPI { // Push a worktree branch to remote push: ( worktreePath: string, - force?: boolean + force?: boolean, + remote?: string ) => Promise<{ success: boolean; result?: { @@ -932,6 +950,7 @@ export interface WorktreeAPI { }>; aheadCount: number; behindCount: number; + hasRemoteBranch: boolean; }; error?: string; code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues @@ -952,6 +971,23 @@ export interface WorktreeAPI { code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES'; }>; + // List all remotes and their branches + listRemotes: (worktreePath: string) => Promise<{ + success: boolean; + result?: { + remotes: Array<{ + name: string; + url: string; + branches: Array<{ + name: string; + fullRef: string; + }>; + }>; + }; + error?: string; + code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; + }>; + // Open a worktree directory in the editor openInEditor: ( worktreePath: string, diff --git a/apps/ui/tests/features/list-view-priority.spec.ts b/apps/ui/tests/features/list-view-priority.spec.ts new file mode 100644 index 00000000..02afda78 --- /dev/null +++ b/apps/ui/tests/features/list-view-priority.spec.ts @@ -0,0 +1,162 @@ +/** + * List View Priority Column E2E Test + * + * Verifies that the list view shows a priority column and allows sorting by priority + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test'); + +test.describe('List View Priority Column', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + const featuresDir = path.join(automakerDir, 'features'); + fs.mkdirSync(featuresDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + // Create test features with different priorities + const features = [ + { + id: 'feature-high-priority', + description: 'High priority feature', + priority: 1, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + { + id: 'feature-medium-priority', + description: 'Medium priority feature', + priority: 2, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + { + id: 'feature-low-priority', + description: 'Low priority feature', + priority: 3, + status: 'backlog', + category: 'test', + createdAt: new Date().toISOString(), + }, + ]; + + // Write each feature to its own directory + for (const feature of features) { + const featureDir = path.join(featuresDir, feature.id); + fs.mkdirSync(featureDir, { recursive: true }); + fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(feature, null, 2)); + } + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: ['test'] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should display priority column in list view and allow sorting', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + // Authenticate before navigating + await authenticateForTests(page); + await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + + // Switch to list view + await page.click('[data-testid="view-toggle-list"]'); + await page.waitForTimeout(500); + + // Verify list view is active + await expect(page.locator('[data-testid="list-view"]')).toBeVisible({ timeout: 5000 }); + + // Verify priority column header exists + await expect(page.locator('[data-testid="list-header-priority"]')).toBeVisible(); + await expect(page.locator('[data-testid="list-header-priority"]')).toContainText('Priority'); + + // Verify priority cells are displayed for our test features + await expect( + page.locator('[data-testid="list-row-priority-feature-high-priority"]') + ).toBeVisible(); + await expect( + page.locator('[data-testid="list-row-priority-feature-medium-priority"]') + ).toBeVisible(); + await expect( + page.locator('[data-testid="list-row-priority-feature-low-priority"]') + ).toBeVisible(); + + // Verify priority badges show H, M, L + const highPriorityCell = page.locator( + '[data-testid="list-row-priority-feature-high-priority"]' + ); + const mediumPriorityCell = page.locator( + '[data-testid="list-row-priority-feature-medium-priority"]' + ); + const lowPriorityCell = page.locator('[data-testid="list-row-priority-feature-low-priority"]'); + + await expect(highPriorityCell).toContainText('H'); + await expect(mediumPriorityCell).toContainText('M'); + await expect(lowPriorityCell).toContainText('L'); + + // Click on priority header to sort + await page.click('[data-testid="list-header-priority"]'); + await page.waitForTimeout(300); + + // Get all rows within the backlog group and verify they are sorted by priority + // (High priority first when sorted ascending by priority value 1, 2, 3) + const backlogGroup = page.locator('[data-testid="list-group-backlog"]'); + const rows = backlogGroup.locator('[data-testid^="list-row-feature-"]'); + + // The first row should be high priority (value 1 = lowest number = first in ascending) + const firstRow = rows.first(); + await expect(firstRow).toHaveAttribute('data-testid', 'list-row-feature-high-priority'); + + // Click again to reverse sort (descending - low priority first) + await page.click('[data-testid="list-header-priority"]'); + await page.waitForTimeout(300); + + // Now the first row should be low priority (value 3 = highest number = first in descending) + const firstRowDesc = rows.first(); + await expect(firstRowDesc).toHaveAttribute('data-testid', 'list-row-feature-low-priority'); + }); +}); diff --git a/libs/dependency-resolver/src/index.ts b/libs/dependency-resolver/src/index.ts index 63fd22e4..fcae1258 100644 --- a/libs/dependency-resolver/src/index.ts +++ b/libs/dependency-resolver/src/index.ts @@ -7,6 +7,8 @@ export { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, getAncestors, diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index 145617f4..02c87c26 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -229,6 +229,49 @@ export function getBlockingDependencies(feature: Feature, allFeatures: Feature[] }); } +/** + * Builds a lookup map for features by id. + * + * @param features - Features to index + * @returns Map keyed by feature id + */ +export function createFeatureMap(features: Feature[]): Map { + const featureMap = new Map(); + for (const feature of features) { + if (feature?.id) { + featureMap.set(feature.id, feature); + } + } + return featureMap; +} + +/** + * Gets the blocking dependencies using a precomputed feature map. + * + * @param feature - Feature to check + * @param featureMap - Map of all features by id + * @returns Array of feature IDs that are blocking this feature + */ +export function getBlockingDependenciesFromMap( + feature: Feature, + featureMap: Map +): string[] { + const dependencies = feature.dependencies; + if (!dependencies || dependencies.length === 0) { + return []; + } + + const blockingDependencies: string[] = []; + for (const depId of dependencies) { + const dep = featureMap.get(depId); + if (dep && dep.status !== 'completed' && dep.status !== 'verified') { + blockingDependencies.push(depId); + } + } + + return blockingDependencies; +} + /** * Checks if adding a dependency from sourceId to targetId would create a circular dependency. * When we say "targetId depends on sourceId", we add sourceId to targetId.dependencies. diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts index 5f246b2a..7f6726f8 100644 --- a/libs/dependency-resolver/tests/resolver.test.ts +++ b/libs/dependency-resolver/tests/resolver.test.ts @@ -3,6 +3,8 @@ import { resolveDependencies, areDependenciesSatisfied, getBlockingDependencies, + createFeatureMap, + getBlockingDependenciesFromMap, wouldCreateCircularDependency, dependencyExists, } from '../src/resolver'; @@ -351,6 +353,21 @@ describe('resolver.ts', () => { }); }); + describe('getBlockingDependenciesFromMap', () => { + it('should match getBlockingDependencies when using a feature map', () => { + const dep1 = createFeature('Dep1', { status: 'pending' }); + const dep2 = createFeature('Dep2', { status: 'completed' }); + const dep3 = createFeature('Dep3', { status: 'running' }); + const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] }); + const allFeatures = [dep1, dep2, dep3, feature]; + const featureMap = createFeatureMap(allFeatures); + + expect(getBlockingDependenciesFromMap(feature, featureMap)).toEqual( + getBlockingDependencies(feature, allFeatures) + ); + }); + }); + describe('wouldCreateCircularDependency', () => { it('should return false for features with no existing dependencies', () => { const features = [createFeature('A'), createFeature('B')]; diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index f9849813..550f635d 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -339,7 +339,7 @@ IMPORTANT CONTEXT (automatically injected): - When deleting a feature, identify which other features depend on it Your task is to analyze the request and produce a structured JSON plan with: -1. Features to ADD (include title, description, category, and dependencies) +1. Features to ADD (include id, title, description, category, and dependencies) 2. Features to UPDATE (specify featureId and the updates) 3. Features to DELETE (specify featureId) 4. A summary of the changes @@ -352,6 +352,7 @@ Respond with ONLY a JSON object in this exact format: { "type": "add", "feature": { + "id": "descriptive-kebab-case-id", "title": "Feature title", "description": "Feature description", "category": "feature" | "bug" | "enhancement" | "refactor", @@ -386,6 +387,8 @@ Respond with ONLY a JSON object in this exact format: \`\`\` Important rules: +- CRITICAL: For new features, always include a descriptive "id" in kebab-case (e.g., "user-authentication", "design-system-foundation") +- Dependencies must reference these exact IDs - both for existing features and new features being added in the same plan - Only include fields that need to change in updates - Ensure dependency references are valid (don't reference deleted features) - Provide clear, actionable descriptions diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 15b641b2..8a10a6f8 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1029,6 +1029,18 @@ export interface GlobalSettings { * Each PhaseModelEntry can specify a providerId for provider-specific models. */ activeClaudeApiProfileId?: string | null; + + /** + * Per-worktree auto mode settings + * Key: "${projectId}::${branchName ?? '__main__'}" + */ + autoModeByWorktree?: Record< + string, + { + maxConcurrency: number; + branchName: string | null; + } + >; } /** @@ -1308,6 +1320,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { // Deprecated - kept for migration claudeApiProfiles: [], activeClaudeApiProfileId: null, + autoModeByWorktree: {}, }; /** Default credentials (empty strings - user must provide API keys) */ diff --git a/package-lock.json b/package-lock.json index c851c9aa..64192c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,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", @@ -5594,9 +5595,19 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.19.tgz", + "integrity": "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.92.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", + "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", "license": "MIT", "funding": { "type": "github", @@ -5604,12 +5615,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "version": "5.90.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", + "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.19" }, "funding": { "type": "github", @@ -5619,6 +5630,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", + "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.92.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.14", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.141.6", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", @@ -6190,7 +6218,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6200,7 +6227,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8411,7 +8438,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11305,6 +11331,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11326,6 +11353,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11347,6 +11375,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11368,6 +11397,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11389,6 +11419,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11410,6 +11441,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11431,6 +11463,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11452,6 +11485,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11473,6 +11507,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11494,6 +11529,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11515,6 +11551,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/start-automaker.sh b/start-automaker.sh index 5d9a30a4..a2029da3 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -9,7 +9,7 @@ set -e # ============================================================================ # CONFIGURATION & CONSTANTS # ============================================================================ - +export $(grep -v '^#' .env | xargs) APP_NAME="Automaker" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HISTORY_FILE="${HOME}/.automaker_launcher_history" @@ -579,7 +579,7 @@ validate_terminal_size() { echo "${C_YELLOW}⚠${RESET} Terminal size ${term_width}x${term_height} is smaller than recommended ${MIN_TERM_WIDTH}x${MIN_TERM_HEIGHT}" echo " Some elements may not display correctly." echo "" - return 1 + return 0 fi } @@ -1154,6 +1154,7 @@ fi # Execute the appropriate command case $MODE in web) + export $(grep -v '^#' .env | xargs) export TEST_PORT="$WEB_PORT" export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export PORT="$SERVER_PORT"