diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index abc5a867..917672b5 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -37,7 +37,14 @@ jobs: git config --global user.email "ci@example.com" - name: Start backend server - run: npm run start --workspace=apps/server & + run: | + echo "Starting backend server..." + # Start server in background and save PID + npm run start --workspace=apps/server > backend.log 2>&1 & + SERVER_PID=$! + echo "Server started with PID: $SERVER_PID" + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + env: PORT: 3008 NODE_ENV: test @@ -53,21 +60,70 @@ jobs: - name: Wait for backend server run: | echo "Waiting for backend server to be ready..." + + # Check if server process is running + if [ -z "$SERVER_PID" ]; then + echo "ERROR: Server PID not found in environment" + cat backend.log 2>/dev/null || echo "No backend log found" + exit 1 + fi + + # Check if process is actually running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process $SERVER_PID is not running!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Recent system logs ===" + dmesg 2>/dev/null | tail -20 || echo "No dmesg available" + exit 1 + fi + + # Wait for health endpoint for i in {1..60}; do if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then echo "Backend server is ready!" - curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "Health check response:" + curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" exit 0 fi + + # Check if server process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process died during wait!" + echo "=== Backend logs ===" + cat backend.log + exit 1 + fi + echo "Waiting... ($i/60)" sleep 1 done - echo "Backend server failed to start!" - echo "Checking server status..." + + echo "ERROR: Backend server failed to start within 60 seconds!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Process status ===" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" - echo "Testing health endpoint..." + lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use" + echo "" + echo "=== Health endpoint test ===" curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed" + + # Kill the server process if it's still hanging + if kill -0 $SERVER_PID 2>/dev/null; then + echo "" + echo "Killing stuck server process..." + kill -9 $SERVER_PID 2>/dev/null || true + fi + exit 1 - name: Run E2E tests @@ -81,6 +137,18 @@ jobs: # Keep UI-side login/defaults consistent AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + - name: Print backend logs on failure + if: failure() + run: | + echo "=== E2E Tests Failed - Backend Logs ===" + cat backend.log 2>/dev/null || echo "No backend log found" + echo "" + echo "=== Process status at failure ===" + ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" + netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() @@ -98,3 +166,13 @@ jobs: apps/ui/test-results/ retention-days: 7 if-no-files-found: ignore + + - name: Cleanup - Kill backend server + if: always() + run: | + if [ -n "$SERVER_PID" ]; then + echo "Cleaning up backend server (PID: $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null || true + kill -9 $SERVER_PID 2>/dev/null || true + echo "Backend server cleanup complete" + fi diff --git a/.gitignore b/.gitignore index 91571307..be8843e0 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,12 @@ docker-compose.override.yml .claude/hans/ pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock + +# Fork-specific workflow files (should never be committed) +DEVELOPMENT_WORKFLOW.md +check-sync.sh +# API key files +data/.api-key +data/credentials.json +data/ diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index dabbc712..f763c08d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -597,6 +597,26 @@ const startServer = (port: number) => { startServer(PORT); +// Global error handlers to prevent crashes from uncaught errors +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + logger.error('Unhandled Promise Rejection:', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + // Don't exit - log the error and continue running + // This prevents the server from crashing due to unhandled rejections +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { + message: error.message, + stack: error.stack, + }); + // Exit on uncaught exceptions to prevent undefined behavior + // The process is in an unknown state after an uncaught exception + process.exit(1); +}); + // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down...'); diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index df412dc6..7ef1aabe 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('SpecRegeneration'); -// Shared state for tracking generation status - private -let isRunning = false; -let currentAbortController: AbortController | null = null; +// Shared state for tracking generation status - scoped by project path +const runningProjects = new Map(); +const abortControllers = new Map(); /** - * Get the current running state + * Get the running state for a specific project */ -export function getSpecRegenerationStatus(): { +export function getSpecRegenerationStatus(projectPath?: string): { isRunning: boolean; currentAbortController: AbortController | null; + projectPath?: string; } { - return { isRunning, currentAbortController }; + if (projectPath) { + return { + isRunning: runningProjects.get(projectPath) || false, + currentAbortController: abortControllers.get(projectPath) || null, + projectPath, + }; + } + // Fallback: check if any project is running (for backward compatibility) + const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); + return { isRunning: isAnyRunning, currentAbortController: null }; } /** - * Set the running state and abort controller + * Get the project path that is currently running (if any) */ -export function setRunningState(running: boolean, controller: AbortController | null = null): void { - isRunning = running; - currentAbortController = controller; +export function getRunningProjectPath(): string | null { + for (const [path, running] of runningProjects.entries()) { + if (running) return path; + } + return null; +} + +/** + * Set the running state and abort controller for a specific project + */ +export function setRunningState( + projectPath: string, + running: boolean, + controller: AbortController | null = null +): void { + if (running) { + runningProjects.set(projectPath, true); + if (controller) { + abortControllers.set(projectPath, controller); + } + } else { + runningProjects.delete(projectPath); + abortControllers.delete(projectPath); + } } /** diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index ed6f68f1..31836867 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) { return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); // Start generation in background @@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) { }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index 0c80a9b6..dc627964 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler( return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Generation already running for this project' }); return; } logAuthStatus('Before starting feature generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background feature generation task...'); generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) @@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler( }) .finally(() => { logger.info('Feature generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate.ts b/apps/server/src/routes/app-spec/routes/generate.ts index a03dacb7..ffc792ae 100644 --- a/apps/server/src/routes/app-spec/routes/generate.ts +++ b/apps/server/src/routes/app-spec/routes/generate.ts @@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); generateSpec( @@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/status.ts b/apps/server/src/routes/app-spec/routes/status.ts index 542dd4f3..34caea32 100644 --- a/apps/server/src/routes/app-spec/routes/status.ts +++ b/apps/server/src/routes/app-spec/routes/status.ts @@ -6,10 +6,11 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; export function createStatusHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { isRunning } = getSpecRegenerationStatus(); - res.json({ success: true, isRunning }); + const projectPath = req.query.projectPath as string | undefined; + const { isRunning } = getSpecRegenerationStatus(projectPath); + res.json({ success: true, isRunning, projectPath }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/app-spec/routes/stop.ts b/apps/server/src/routes/app-spec/routes/stop.ts index 0751147b..2a7b0aab 100644 --- a/apps/server/src/routes/app-spec/routes/stop.ts +++ b/apps/server/src/routes/app-spec/routes/stop.ts @@ -6,13 +6,16 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; export function createStopHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { currentAbortController } = getSpecRegenerationStatus(); + const { projectPath } = req.body as { projectPath?: string }; + const { currentAbortController } = getSpecRegenerationStatus(projectPath); if (currentAbortController) { currentAbortController.abort(); } - setRunningState(false, null); + if (projectPath) { + setRunningState(projectPath, false, null); + } res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 5f36d691..16dbd197 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js'; import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createApprovePlanHandler } from './routes/approve-plan.js'; +import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); @@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router { validatePathParams('projectPath'), createApprovePlanHandler(autoModeService) ); + router.post( + '/resume-interrupted', + validatePathParams('projectPath'), + createResumeInterruptedHandler(autoModeService) + ); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts new file mode 100644 index 00000000..36cda2bd --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -0,0 +1,42 @@ +/** + * Resume Interrupted Features Handler + * + * Checks for features that were interrupted (in pipeline steps or in_progress) + * when the server was restarted and resumes them. + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; + +const logger = createLogger('ResumeInterrupted'); + +interface ResumeInterruptedRequest { + projectPath: string; +} + +export function createResumeInterruptedHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ResumeInterruptedRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Checking for interrupted features in ${projectPath}`); + + try { + await autoModeService.resumeInterruptedFeatures(projectPath); + res.json({ + success: true, + message: 'Resume check completed', + }); + } catch (error) { + logger.error('Error resuming interrupted features:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 4c3a9da4..2331fdd4 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -188,6 +188,7 @@ export function createEnhanceHandler( technical: prompts.enhancement.technicalSystemPrompt, simplify: prompts.enhancement.simplifySystemPrompt, acceptance: prompts.enhancement.acceptanceSystemPrompt, + 'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt, }; const systemPrompt = systemPromptMap[validMode]; diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 4f62ee17..e0435f35 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js'; import { createCreateHandler } from './routes/create.js'; import { createUpdateHandler } from './routes/update.js'; import { createBulkUpdateHandler } from './routes/bulk-update.js'; +import { createBulkDeleteHandler } from './routes/bulk-delete.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; @@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { validatePathParams('projectPath'), createBulkUpdateHandler(featureLoader) ); + router.post( + '/bulk-delete', + validatePathParams('projectPath'), + createBulkDeleteHandler(featureLoader) + ); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); diff --git a/apps/server/src/routes/features/routes/bulk-delete.ts b/apps/server/src/routes/features/routes/bulk-delete.ts new file mode 100644 index 00000000..555515ae --- /dev/null +++ b/apps/server/src/routes/features/routes/bulk-delete.ts @@ -0,0 +1,61 @@ +/** + * POST /bulk-delete endpoint - Delete multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkDeleteRequest { + projectPath: string; + featureIds: string[]; +} + +interface BulkDeleteResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds } = req.body as BulkDeleteRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + const results = await Promise.all( + featureIds.map(async (featureId) => { + const success = await featureLoader.delete(projectPath, featureId); + if (success) { + return { featureId, success: true }; + } + return { + featureId, + success: false, + error: 'Deletion failed. Check server logs for details.', + }; + }) + ); + + const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); + const failureCount = results.length - successCount; + + res.json({ + success: failureCount === 0, + deletedCount: successCount, + failedCount: failureCount, + results, + }); + } catch (error) { + logError(error, 'Bulk delete features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 2e960a62..1a89cda3 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -10,14 +10,21 @@ import { getErrorMessage, logError } from '../common.js'; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } = - req.body as { - projectPath: string; - featureId: string; - updates: Partial; - descriptionHistorySource?: 'enhance' | 'edit'; - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'; - }; + const { + projectPath, + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription, + } = req.body as { + projectPath: string; + featureId: string; + updates: Partial; + descriptionHistorySource?: 'enhance' | 'edit'; + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'; + preEnhancementDescription?: string; + }; if (!projectPath || !featureId || !updates) { res.status(400).json({ @@ -32,7 +39,8 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { featureId, updates, descriptionHistorySource, - enhancementMode + enhancementMode, + preEnhancementDescription ); res.json({ success: true, feature: updated }); } catch (error) { diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 54f7ba9e..a00e0bfe 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -25,6 +25,8 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js'; import { createOpenInEditorHandler, createGetDefaultEditorHandler, + createGetAvailableEditorsHandler, + createRefreshEditorsHandler, } from './routes/open-in-editor.js'; import { createInitGitHandler } from './routes/init-git.js'; import { createMigrateHandler } from './routes/migrate.js'; @@ -84,6 +86,8 @@ export function createWorktreeRoutes(events: EventEmitter): Router { router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler()); router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); router.get('/default-editor', createGetDefaultEditorHandler()); + router.get('/available-editors', createGetAvailableEditorsHandler()); + router.post('/refresh-editors', createRefreshEditorsHandler()); router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); router.post('/migrate', createMigrateHandler()); router.post( diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 93d93dad..bc70a341 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -2,18 +2,23 @@ * POST /list endpoint - List all git worktrees * * Returns actual git worktrees from `git worktree list`. + * Also scans .worktrees/ directory to discover worktrees that may have been + * created externally or whose git state was corrupted. * Does NOT include tracked branches - only real worktrees with separate directories. */ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); interface WorktreeInfo { path: string; @@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise { } } +/** + * Scan the .worktrees directory to discover worktrees that may exist on disk + * but are not registered with git (e.g., created externally or corrupted state). + */ +async function scanWorktreesDirectory( + projectPath: string, + knownWorktreePaths: Set +): Promise> { + const discovered: Array<{ path: string; branch: string }> = []; + const worktreesDir = path.join(projectPath, '.worktrees'); + + try { + // Check if .worktrees directory exists + await secureFs.access(worktreesDir); + } catch { + // .worktrees directory doesn't exist + return discovered; + } + + try { + const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + const normalizedPath = normalizePath(worktreePath); + + // Skip if already known from git worktree list + if (knownWorktreePaths.has(normalizedPath)) continue; + + // Check if this is a valid git repository + const gitPath = path.join(worktreePath, '.git'); + try { + const gitStat = await secureFs.stat(gitPath); + + // Git worktrees have a .git FILE (not directory) that points to the parent repo + // Regular repos have a .git DIRECTORY + if (gitStat.isFile() || gitStat.isDirectory()) { + // Try to get the branch name + const branch = await getCurrentBranch(worktreePath); + if (branch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})` + ); + discovered.push({ + path: normalizedPath, + branch, + }); + } else { + // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + try { + const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const headBranch = headRef.trim(); + if (headBranch && headBranch !== 'HEAD') { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); + } + } catch { + // Can't determine branch, skip this directory + } + } + } + } catch { + // Not a git repo, skip + } + } + } catch (error) { + logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`); + } + + return discovered; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -116,6 +202,22 @@ export function createListHandler() { } } + // Scan .worktrees directory to discover worktrees that exist on disk + // but are not registered with git (e.g., created externally) + const knownPaths = new Set(worktrees.map((w) => w.path)); + const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths); + + // Add discovered worktrees to the list + for (const discovered of discoveredWorktrees) { + worktrees.push({ + path: discovered.path, + branch: discovered.branch, + isMain: false, + isCurrent: discovered.branch === currentBranch, + hasWorktree: true, + }); + } + // Read all worktree metadata to get PR info const allMetadata = await readAllWorktreeMetadata(projectPath); diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts index 40e71b00..c5ea6f9e 100644 --- a/apps/server/src/routes/worktree/routes/open-in-editor.ts +++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -1,78 +1,40 @@ /** * POST /open-in-editor endpoint - Open a worktree directory in the default code editor * GET /default-editor endpoint - Get the name of the default code editor + * POST /refresh-editors endpoint - Clear editor cache and re-detect available editors + * + * This module uses @automaker/platform for cross-platform editor detection and launching. */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { isAbsolute } from 'path'; +import { + clearEditorCache, + detectAllEditors, + detectDefaultEditor, + openInEditor, + openInFileManager, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; -const execAsync = promisify(exec); +const logger = createLogger('open-in-editor'); -// Editor detection with caching -interface EditorInfo { - name: string; - command: string; -} - -let cachedEditor: EditorInfo | null = null; - -/** - * Detect which code editor is available on the system - */ -async function detectDefaultEditor(): Promise { - // Return cached result if available - if (cachedEditor) { - return cachedEditor; - } - - // Try Cursor first (if user has Cursor, they probably prefer it) - try { - await execAsync('which cursor || where cursor'); - cachedEditor = { name: 'Cursor', command: 'cursor' }; - return cachedEditor; - } catch { - // Cursor not found - } - - // Try VS Code - try { - await execAsync('which code || where code'); - cachedEditor = { name: 'VS Code', command: 'code' }; - return cachedEditor; - } catch { - // VS Code not found - } - - // Try Zed - try { - await execAsync('which zed || where zed'); - cachedEditor = { name: 'Zed', command: 'zed' }; - return cachedEditor; - } catch { - // Zed not found - } - - // Try Sublime Text - try { - await execAsync('which subl || where subl'); - cachedEditor = { name: 'Sublime Text', command: 'subl' }; - return cachedEditor; - } catch { - // Sublime not found - } - - // Fallback to file manager - const platform = process.platform; - if (platform === 'darwin') { - cachedEditor = { name: 'Finder', command: 'open' }; - } else if (platform === 'win32') { - cachedEditor = { name: 'Explorer', command: 'explorer' }; - } else { - cachedEditor = { name: 'File Manager', command: 'xdg-open' }; - } - return cachedEditor; +export function createGetAvailableEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const editors = await detectAllEditors(); + res.json({ + success: true, + result: { + editors, + }, + }); + } catch (error) { + logError(error, 'Get available editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; } export function createGetDefaultEditorHandler() { @@ -93,11 +55,41 @@ export function createGetDefaultEditorHandler() { }; } +/** + * Handler to refresh the editor cache and re-detect available editors + * Useful when the user has installed/uninstalled editors + */ +export function createRefreshEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearEditorCache(); + + // Re-detect editors (this will repopulate the cache) + const editors = await detectAllEditors(); + + logger.info(`Editor cache refreshed, found ${editors.length} editors`); + + res.json({ + success: true, + result: { + editors, + message: `Found ${editors.length} available editors`, + }, + }); + } catch (error) { + logError(error, 'Refresh editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + export function createOpenInEditorHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, editorCommand } = req.body as { worktreePath: string; + editorCommand?: string; }; if (!worktreePath) { @@ -108,42 +100,44 @@ export function createOpenInEditorHandler() { return; } - const editor = await detectDefaultEditor(); + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } try { - await execAsync(`${editor.command} "${worktreePath}"`); + // Use the platform utility to open in editor + const result = await openInEditor(worktreePath, editorCommand); res.json({ success: true, result: { - message: `Opened ${worktreePath} in ${editor.name}`, - editorName: editor.name, + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, }, }); } catch (editorError) { - // If the detected editor fails, try opening in default file manager as fallback - const platform = process.platform; - let openCommand: string; - let fallbackName: string; + // If the specified editor fails, try opening in default file manager as fallback + logger.warn( + `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` + ); - if (platform === 'darwin') { - openCommand = `open "${worktreePath}"`; - fallbackName = 'Finder'; - } else if (platform === 'win32') { - openCommand = `explorer "${worktreePath}"`; - fallbackName = 'Explorer'; - } else { - openCommand = `xdg-open "${worktreePath}"`; - fallbackName = 'File Manager'; + try { + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); + } catch (fallbackError) { + // Both editor and file manager failed + throw fallbackError; } - - await execAsync(openCommand); - res.json({ - success: true, - result: { - message: `Opened ${worktreePath} in ${fallbackName}`, - editorName: fallbackName, - }, - }); } } catch (error) { logError(error, 'Open in editor failed'); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index a2be666f..b830a297 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -31,7 +31,13 @@ import { const logger = createLogger('AutoMode'); import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; +import { + getFeatureDir, + getAutomakerDir, + getFeaturesDir, + getExecutionStatePath, + ensureAutomakerDir, +} from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -201,6 +207,29 @@ interface AutoModeConfig { projectPath: string; } +/** + * Execution state for recovery after server restart + * Tracks which features were running and auto-loop configuration + */ +interface ExecutionState { + version: 1; + autoLoopWasRunning: boolean; + maxConcurrency: number; + projectPath: string; + runningFeatureIds: string[]; + savedAt: string; +} + +// Default empty execution state +const DEFAULT_EXECUTION_STATE: ExecutionState = { + version: 1, + autoLoopWasRunning: false, + maxConcurrency: 3, + projectPath: '', + runningFeatureIds: [], + savedAt: '', +}; + // Constants for consecutive failure tracking const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive @@ -322,6 +351,9 @@ export class AutoModeService { projectPath, }); + // Save execution state for recovery after restart + await this.saveExecutionState(projectPath); + // Note: Memory folder initialization is now handled by loadContextFiles // Run the loop in the background @@ -390,17 +422,23 @@ export class AutoModeService { */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; + const projectPath = this.config?.projectPath; this.autoLoopRunning = false; if (this.autoLoopAbortController) { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } + // Clear execution state when auto-loop is explicitly stopped + if (projectPath) { + await this.clearExecutionState(projectPath); + } + // Emit stop event immediately when user explicitly stops if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', - projectPath: this.config?.projectPath, + projectPath, }); } @@ -441,6 +479,11 @@ export class AutoModeService { }; this.runningFeatures.set(featureId, tempRunningFeature); + // Save execution state when feature starts + if (isAutoMode) { + await this.saveExecutionState(projectPath); + } + try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); @@ -695,6 +738,11 @@ export class AutoModeService { `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); this.runningFeatures.delete(featureId); + + // Update execution state after feature completes + if (this.autoLoopRunning && projectPath) { + await this.saveExecutionState(projectPath); + } } } @@ -2950,6 +2998,149 @@ Begin implementing task ${task.id} now.`; }); } + // ============================================================================ + // Execution State Persistence - For recovery after server restart + // ============================================================================ + + /** + * Save execution state to disk for recovery after server restart + */ + private async saveExecutionState(projectPath: string): Promise { + try { + await ensureAutomakerDir(projectPath); + const statePath = getExecutionStatePath(projectPath); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: this.autoLoopRunning, + maxConcurrency: this.config?.maxConcurrency ?? 3, + projectPath, + runningFeatureIds: Array.from(this.runningFeatures.keys()), + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); + } catch (error) { + logger.error('Failed to save execution state:', error); + } + } + + /** + * Load execution state from disk + */ + private async loadExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + const content = (await secureFs.readFile(statePath, 'utf-8')) as string; + const state = JSON.parse(content) as ExecutionState; + return state; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to load execution state:', error); + } + return DEFAULT_EXECUTION_STATE; + } + } + + /** + * Clear execution state (called on successful shutdown or when auto-loop stops) + */ + private async clearExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + await secureFs.unlink(statePath); + logger.info('Cleared execution state'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to clear execution state:', error); + } + } + } + + /** + * Check for and resume interrupted features after server restart + * This should be called during server initialization + */ + async resumeInterruptedFeatures(projectPath: string): Promise { + logger.info('Checking for interrupted features to resume...'); + + // Load all features and find those that were interrupted + const featuresDir = getFeaturesDir(projectPath); + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + const interruptedFeatures: Feature[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + try { + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; + const feature = JSON.parse(data) as Feature; + + // Check if feature was interrupted (in_progress or pipeline_*) + if ( + feature.status === 'in_progress' || + (feature.status && feature.status.startsWith('pipeline_')) + ) { + // Verify it has existing context (agent-output.md) + const featureDir = getFeatureDir(projectPath, feature.id); + const contextPath = path.join(featureDir, 'agent-output.md'); + try { + await secureFs.access(contextPath); + interruptedFeatures.push(feature); + logger.info( + `Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` + ); + } catch { + // No context file, skip this feature - it will be restarted fresh + logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); + } + } + } catch { + // Skip invalid features + } + } + } + + if (interruptedFeatures.length === 0) { + logger.info('No interrupted features found'); + return; + } + + logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_resuming_features', { + message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`, + projectPath, + featureIds: interruptedFeatures.map((f) => f.id), + features: interruptedFeatures.map((f) => ({ + id: f.id, + title: f.title, + status: f.status, + })), + }); + + // Resume each interrupted feature + for (const feature of interruptedFeatures) { + try { + logger.info(`Resuming feature: ${feature.id} (${feature.title})`); + // Use resumeFeature which will detect the existing context and continue + await this.resumeFeature(projectPath, feature.id, true); + } catch (error) { + logger.error(`Failed to resume feature ${feature.id}:`, error); + // Continue with other features + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info('No features directory found, nothing to resume'); + } else { + logger.error('Error checking for interrupted features:', error); + } + } + } + /** * Extract and record learnings from a completed feature * Uses a quick Claude call to identify important decisions and patterns diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 098ce29c..64ace35d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import * as os from 'os'; import * as pty from 'node-pty'; import { ClaudeUsage } from '../routes/claude/types.js'; +import { createLogger } from '@automaker/utils'; /** * Claude Usage Service @@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js'; * - macOS: Uses 'expect' command for PTY * - Windows/Linux: Uses node-pty for PTY */ +const logger = createLogger('ClaudeUsage'); + export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout @@ -164,21 +167,40 @@ export class ClaudeUsageService { const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; - const ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + let ptyProcess: any = null; + + try { + ptyProcess = pty.spawn(shell, args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }); + } catch (spawnError) { + // pty.spawn() can throw synchronously if the native module fails to load + // or if PTY is not available in the current environment (e.g., containers without /dev/pts) + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + + // Return a user-friendly error instead of crashing + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); @@ -188,7 +210,7 @@ export class ClaudeUsageService { } }, this.timeout); - ptyProcess.onData((data) => { + ptyProcess.onData((data: string) => { output += data; // Check if we've seen the usage data (look for "Current session") @@ -196,12 +218,12 @@ export class ClaudeUsageService { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.kill('SIGTERM'); } }, 2000); @@ -212,14 +234,14 @@ export class ClaudeUsageService { // Fallback: if we see "Esc to cancel" but haven't seen usage data yet if (!hasSeenUsageData && output.includes('Esc to cancel')) { setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } }, 3000); } }); - ptyProcess.onExit(({ exitCode }) => { + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { clearTimeout(timeoutId); if (settled) return; settled = true; diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 93cff796..409abd2a 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -308,13 +308,15 @@ export class FeatureLoader { * @param updates - Partial feature updates * @param descriptionHistorySource - Source of description change ('enhance' or 'edit') * @param enhancementMode - Enhancement mode if source is 'enhance' + * @param preEnhancementDescription - Description before enhancement (for restoring original) */ async update( projectPath: string, featureId: string, updates: Partial, descriptionHistorySource?: 'enhance' | 'edit', - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer', + preEnhancementDescription?: string ): Promise { const feature = await this.get(projectPath, featureId); if (!feature) { @@ -338,9 +340,31 @@ export class FeatureLoader { updates.description !== feature.description && updates.description.trim() ) { + const timestamp = new Date().toISOString(); + + // If this is an enhancement and we have the pre-enhancement description, + // add the original text to history first (so user can restore to it) + if ( + descriptionHistorySource === 'enhance' && + preEnhancementDescription && + preEnhancementDescription.trim() + ) { + // Check if this pre-enhancement text is different from the last history entry + const lastEntry = updatedHistory[updatedHistory.length - 1]; + if (!lastEntry || lastEntry.description !== preEnhancementDescription) { + const preEnhanceEntry: DescriptionHistoryEntry = { + description: preEnhancementDescription, + timestamp, + source: updatedHistory.length === 0 ? 'initial' : 'edit', + }; + updatedHistory = [...updatedHistory, preEnhanceEntry]; + } + } + + // Add the new/enhanced description to history const historyEntry: DescriptionHistoryEntry = { description: updates.description, - timestamp: new Date().toISOString(), + timestamp, source: descriptionHistorySource || 'edit', ...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}), }; diff --git a/apps/ui/src/components/icons/editor-icons.tsx b/apps/ui/src/components/icons/editor-icons.tsx new file mode 100644 index 00000000..a4537d5f --- /dev/null +++ b/apps/ui/src/components/icons/editor-icons.tsx @@ -0,0 +1,220 @@ +import type { ComponentType, ComponentProps } from 'react'; +import { FolderOpen } from 'lucide-react'; + +type IconProps = ComponentProps<'svg'>; +type IconComponent = ComponentType; + +const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const; +const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS; + +/** + * Cursor editor logo icon - from LobeHub icons + */ +export function CursorIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code editor logo icon + */ +export function VSCodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * VS Code Insiders editor logo icon (same as VS Code) + */ +export function VSCodeInsidersIcon(props: IconProps) { + return ; +} + +/** + * Kiro editor logo icon (VS Code fork) + */ +export function KiroIcon(props: IconProps) { + return ( + + + + + ); +} + +/** + * Zed editor logo icon (from Simple Icons) + */ +export function ZedIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Sublime Text editor logo icon + */ +export function SublimeTextIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * macOS Finder icon + */ +export function FinderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Windsurf editor logo icon (by Codeium) - from LobeHub icons + */ +export function WindsurfIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Trae editor logo icon (by ByteDance) - from LobeHub icons + */ +export function TraeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains Rider logo icon + */ +export function RiderIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * JetBrains WebStorm logo icon + */ +export function WebStormIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Xcode logo icon + */ +export function XcodeIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Android Studio logo icon + */ +export function AndroidStudioIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Google Antigravity IDE logo icon - stylized "A" arch shape + */ +export function AntigravityIcon(props: IconProps) { + return ( + + + + ); +} + +/** + * Get the appropriate icon component for an editor command + */ +export function getEditorIcon(command: string): IconComponent { + // Handle direct CLI commands + const cliIcons: Record = { + cursor: CursorIcon, + code: VSCodeIcon, + 'code-insiders': VSCodeInsidersIcon, + kido: KiroIcon, + zed: ZedIcon, + subl: SublimeTextIcon, + windsurf: WindsurfIcon, + trae: TraeIcon, + rider: RiderIcon, + webstorm: WebStormIcon, + xed: XcodeIcon, + studio: AndroidStudioIcon, + [PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + [LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon, + open: FinderIcon, + explorer: FolderOpen, + 'xdg-open': FolderOpen, + }; + + // Check direct match first + if (cliIcons[command]) { + return cliIcons[command]; + } + + // Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"' + if (command.startsWith('open')) { + const cmdLower = command.toLowerCase(); + if (cmdLower.includes('cursor')) return CursorIcon; + if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon; + if (cmdLower.includes('visual studio code')) return VSCodeIcon; + if (cmdLower.includes('kiro')) return KiroIcon; + if (cmdLower.includes('zed')) return ZedIcon; + if (cmdLower.includes('sublime')) return SublimeTextIcon; + if (cmdLower.includes('windsurf')) return WindsurfIcon; + if (cmdLower.includes('trae')) return TraeIcon; + if (cmdLower.includes('rider')) return RiderIcon; + if (cmdLower.includes('webstorm')) return WebStormIcon; + if (cmdLower.includes('xcode')) return XcodeIcon; + if (cmdLower.includes('android studio')) return AndroidStudioIcon; + if (cmdLower.includes('antigravity')) return AntigravityIcon; + // If just 'open' without app name, it's Finder + if (command === 'open') return FinderIcon; + } + + return FolderOpen; +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 2eff16c0..2933453a 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -126,6 +126,9 @@ export function Sidebar() { // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; + // Check if the current project is specifically the one generating spec + const isCurrentProjectGeneratingSpec = + specCreatingForProject !== null && specCreatingForProject === currentProject?.path; // Auto-collapse sidebar on small screens and update Electron window minWidth useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); @@ -241,6 +244,7 @@ export function Sidebar() { cyclePrevProject, cycleNextProject, unviewedValidationsCount, + isSpecGenerating: isCurrentProjectGeneratingSpec, }); // Register keyboard shortcuts diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index 65b1bc13..825db5cd 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -1,4 +1,5 @@ import type { NavigateOptions } from '@tanstack/react-router'; +import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import type { NavSection } from '../types'; @@ -80,14 +81,23 @@ export function SidebarNavigation({ data-testid={`nav-${item.id}`} >
- + {item.isLoading ? ( + + ) : ( + + )} {/* Count badge for collapsed state */} {!sidebarOpen && item.count !== undefined && item.count > 0 && ( { + isGeneratingRef.current = true; + onGenerateSpec(); + }; + return ( { - if (!isOpen) { + if (!isOpen && !isGeneratingRef.current) { + // Only call onSkip when user dismisses dialog (escape, click outside, or skip button) + // NOT when they click "Generate App Spec" onSkip(); } + isGeneratingRef.current = false; onOpenChange(isOpen); }} > @@ -108,7 +121,7 @@ export function OnboardingDialog({ Skip for now - - -

Kanban Board View

-
- - - - - - -

Dependency Graph View

-
-
-
- +
{/* Board Background Button */} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 4611c843..5a9b7302 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -10,9 +10,10 @@ import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { getHttpApiClient } from '@/lib/http-api-client'; +import { BoardSearchBar } from './board-search-bar'; +import { BoardControls } from './board-controls'; interface BoardHeaderProps { - projectName: string; projectPath: string; maxConcurrency: number; runningAgentsCount: number; @@ -21,6 +22,15 @@ interface BoardHeaderProps { onAutoModeToggle: (enabled: boolean) => void; onOpenPlanDialog: () => void; isMounted: boolean; + // Search bar props + searchQuery: string; + onSearchChange: (query: string) => void; + isCreatingSpec: boolean; + creatingSpecProjectPath?: string; + // Board controls props + onShowBoardBackground: () => void; + onShowCompletedModal: () => void; + completedCount: number; } // Shared styles for header control containers @@ -28,7 +38,6 @@ const controlContainerClass = 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border'; export function BoardHeader({ - projectName, projectPath, maxConcurrency, runningAgentsCount, @@ -37,6 +46,13 @@ export function BoardHeader({ onAutoModeToggle, onOpenPlanDialog, isMounted, + searchQuery, + onSearchChange, + isCreatingSpec, + creatingSpecProjectPath, + onShowBoardBackground, + onShowCompletedModal, + completedCount, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); @@ -84,9 +100,20 @@ export function BoardHeader({ return (
-
-

Kanban Board

-

{projectName}

+
+ +
{/* Usage Popover - show if either provider is authenticated */} 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 b73a8d04..87268652 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 @@ -70,6 +70,7 @@ export function AgentInfoPanel({ }: AgentInfoPanelProps) { const [agentInfo, setAgentInfo] = useState(null); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); + const [isTodosExpanded, setIsTodosExpanded] = useState(false); useEffect(() => { const loadContext = async () => { @@ -197,32 +198,47 @@ export function AgentInfoPanel({ {agentInfo.todos.length} tasks
-
- {agentInfo.todos.slice(0, 3).map((todo, idx) => ( -
- {todo.status === 'completed' ? ( - - ) : todo.status === 'in_progress' ? ( - - ) : ( - - )} - + {(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map( + (todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + )} - > - {todo.content} - -
- ))} + + {todo.content} + +
+ ) + )} {agentInfo.todos.length > 3 && ( -

- +{agentInfo.todos.length - 3} more -

+ )}
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx index db9f579d..11e98663 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -35,6 +35,7 @@ export function SummaryDialog({ data-testid={`summary-dialog-${feature.id}`} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} > diff --git a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx index 7f4a553a..7938d05e 100644 --- a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx +++ b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx @@ -1,11 +1,21 @@ +import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Pencil, X, CheckSquare } from 'lucide-react'; +import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; interface SelectionActionBarProps { selectedCount: number; totalCount: number; onEdit: () => void; + onDelete: () => void; onClear: () => void; onSelectAll: () => void; } @@ -14,65 +24,126 @@ export function SelectionActionBar({ selectedCount, totalCount, onEdit, + onDelete, onClear, onSelectAll, }: SelectionActionBarProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + if (selectedCount === 0) return null; const allSelected = selectedCount === totalCount; + const handleDeleteClick = () => { + setShowDeleteDialog(true); + }; + + const handleConfirmDelete = () => { + setShowDeleteDialog(false); + onDelete(); + }; + return ( -
- - {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected - + <> +
+ + {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected + -
+
-
- +
+ - {!allSelected && ( - )} - + {!allSelected && ( + + )} + + +
-
+ + {/* Delete Confirmation Dialog */} + + + + + + Delete Selected Features? + + + Are you sure you want to permanently delete {selectedCount} feature + {selectedCount !== 1 ? 's' : ''}? + + This action cannot be undone. + + + + + + + + + + ); } diff --git a/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx new file mode 100644 index 00000000..c2eec445 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx @@ -0,0 +1,254 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import type { PipelineStep } from '@automaker/types'; +import { cn } from '@/lib/utils'; +import { STEP_TEMPLATES } from './pipeline-step-templates'; + +// Color options for pipeline columns +const COLOR_OPTIONS = [ + { value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' }, + { value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' }, + { value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' }, + { value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' }, + { value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' }, + { value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' }, + { value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' }, + { value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' }, + { value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' }, +]; + +interface AddEditPipelineStepDialogProps { + open: boolean; + onClose: () => void; + onSave: (step: Omit & { id?: string }) => void; + existingStep?: PipelineStep | null; + defaultOrder: number; +} + +export function AddEditPipelineStepDialog({ + open, + onClose, + onSave, + existingStep, + defaultOrder, +}: AddEditPipelineStepDialogProps) { + const isEditing = !!existingStep; + const fileInputRef = useRef(null); + + const [name, setName] = useState(''); + const [instructions, setInstructions] = useState(''); + const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Reset form when dialog opens/closes or existingStep changes + useEffect(() => { + if (open) { + if (existingStep) { + setName(existingStep.name); + setInstructions(existingStep.instructions); + setColorClass(existingStep.colorClass); + setSelectedTemplate(null); + } else { + setName(''); + setInstructions(''); + setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value); + setSelectedTemplate(null); + } + } + }, [open, existingStep, defaultOrder]); + + const handleTemplateClick = (templateId: string) => { + const template = STEP_TEMPLATES.find((t) => t.id === templateId); + if (template) { + setName(template.name); + setInstructions(template.instructions); + setColorClass(template.colorClass); + setSelectedTemplate(templateId); + toast.success(`Loaded "${template.name}" template`); + } + }; + + const handleFileUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const content = await file.text(); + setInstructions(content); + toast.success('Instructions loaded from file'); + } catch { + toast.error('Failed to load file'); + } + + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSave = () => { + if (!name.trim()) { + toast.error('Step name is required'); + return; + } + + if (!instructions.trim()) { + toast.error('Step instructions are required'); + return; + } + + onSave({ + id: existingStep?.id, + name: name.trim(), + instructions: instructions.trim(), + colorClass, + order: existingStep?.order ?? defaultOrder, + }); + + onClose(); + }; + + return ( + !isOpen && onClose()}> + + {/* Hidden file input for loading instructions from .md files */} + + + + {isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'} + + {isEditing + ? 'Modify the step configuration below.' + : 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'} + + + +
+ {/* Template Quick Start - Only show for new steps */} + {!isEditing && ( +
+ +
+ {STEP_TEMPLATES.map((template) => ( + + ))} +
+

+ Click a template to pre-fill the form, then customize as needed. +

+
+ )} + + {/* Divider */} + {!isEditing &&
} + + {/* Step Name */} +
+ + setName(e.target.value)} + autoFocus={isEditing} + /> +
+ + {/* Color Selection */} +
+ +
+ {COLOR_OPTIONS.map((color) => ( +
+
+ + {/* Agent Instructions */} +
+
+ + +
+