diff --git a/apps/server/src/lib/error-handler.ts b/apps/server/src/lib/error-handler.ts index b1e0c3ac..1ddc83a2 100644 --- a/apps/server/src/lib/error-handler.ts +++ b/apps/server/src/lib/error-handler.ts @@ -21,6 +21,22 @@ export function isAbortError(error: unknown): boolean { ); } +/** + * Check if an error is a user-initiated cancellation + * + * @param errorMessage - The error message to check + * @returns True if the error is a user-initiated cancellation + */ +export function isCancellationError(errorMessage: string): boolean { + const lowerMessage = errorMessage.toLowerCase(); + return ( + lowerMessage.includes("cancelled") || + lowerMessage.includes("canceled") || + lowerMessage.includes("stopped") || + lowerMessage.includes("aborted") + ); +} + /** * Check if an error is an authentication/API key error * @@ -39,7 +55,7 @@ export function isAuthenticationError(errorMessage: string): boolean { /** * Error type classification */ -export type ErrorType = "authentication" | "abort" | "execution" | "unknown"; +export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown"; /** * Classified error information @@ -49,6 +65,7 @@ export interface ErrorInfo { message: string; isAbort: boolean; isAuth: boolean; + isCancellation: boolean; originalError: unknown; } @@ -62,12 +79,15 @@ export function classifyError(error: unknown): ErrorInfo { const message = error instanceof Error ? error.message : String(error || "Unknown error"); const isAbort = isAbortError(error); const isAuth = isAuthenticationError(message); + const isCancellation = isCancellationError(message); let type: ErrorType; if (isAuth) { type = "authentication"; } else if (isAbort) { type = "abort"; + } else if (isCancellation) { + type = "cancellation"; } else if (error instanceof Error) { type = "execution"; } else { @@ -79,6 +99,7 @@ export function classifyError(error: unknown): ErrorInfo { message, isAbort, isAuth, + isCancellation, originalError: error, }; } diff --git a/apps/server/src/routes/app-spec/index.ts b/apps/server/src/routes/app-spec/index.ts index 9964289c..b37907c8 100644 --- a/apps/server/src/routes/app-spec/index.ts +++ b/apps/server/src/routes/app-spec/index.ts @@ -23,3 +23,4 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router { } + diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 93253c47..8ad4510c 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -6,8 +6,6 @@ import { Router } from "express"; import type { AutoModeService } from "../../services/auto-mode-service.js"; -import { createStartHandler } from "./routes/start.js"; -import { createStopHandler } from "./routes/stop.js"; import { createStopFeatureHandler } from "./routes/stop-feature.js"; import { createStatusHandler } from "./routes/status.js"; import { createRunFeatureHandler } from "./routes/run-feature.js"; @@ -17,12 +15,11 @@ import { createContextExistsHandler } from "./routes/context-exists.js"; 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"; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); - router.post("/start", createStartHandler(autoModeService)); - router.post("/stop", createStopHandler(autoModeService)); router.post("/stop-feature", createStopFeatureHandler(autoModeService)); router.post("/status", createStatusHandler(autoModeService)); router.post("/run-feature", createRunFeatureHandler(autoModeService)); @@ -35,6 +32,7 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router { createFollowUpFeatureHandler(autoModeService) ); router.post("/commit-feature", createCommitFeatureHandler(autoModeService)); + router.post("/approve-plan", createApprovePlanHandler(autoModeService)); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/apps/server/src/routes/auto-mode/routes/approve-plan.ts new file mode 100644 index 00000000..744f9f18 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/approve-plan.ts @@ -0,0 +1,78 @@ +/** + * POST /approve-plan endpoint - Approve or reject a generated plan/spec + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("AutoMode"); + +export function createApprovePlanHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + try { + const { featureId, approved, editedPlan, feedback, projectPath } = req.body as { + featureId: string; + approved: boolean; + editedPlan?: string; + feedback?: string; + projectPath?: string; + }; + + if (!featureId) { + res.status(400).json({ + success: false, + error: "featureId is required", + }); + return; + } + + if (typeof approved !== "boolean") { + res.status(400).json({ + success: false, + error: "approved must be a boolean", + }); + return; + } + + // Note: We no longer check hasPendingApproval here because resolvePlanApproval + // can handle recovery when pending approval is not in Map but feature has planSpec.status='generated' + // This supports cases where the server restarted while waiting for approval + + logger.info( + `[AutoMode] Plan ${approved ? "approved" : "rejected"} for feature ${featureId}${ + editedPlan ? " (with edits)" : "" + }${feedback ? ` - Feedback: ${feedback}` : ""}` + ); + + // Resolve the pending approval (with recovery support) + const result = await autoModeService.resolvePlanApproval( + featureId, + approved, + editedPlan, + feedback, + projectPath + ); + + if (!result.success) { + res.status(500).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + approved, + message: approved + ? "Plan approved - implementation will continue" + : "Plan rejected - feature execution stopped", + }); + } catch (error) { + logError(error, "Approve plan failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts index aa8887ad..1b470a25 100644 --- a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -12,13 +12,14 @@ const logger = createLogger("AutoMode"); export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, prompt, imagePaths, worktreePath } = req.body as { - projectPath: string; - featureId: string; - prompt: string; - imagePaths?: string[]; - worktreePath?: string; - }; + const { projectPath, featureId, prompt, imagePaths, useWorktrees } = + req.body as { + projectPath: string; + featureId: string; + prompt: string; + imagePaths?: string[]; + useWorktrees?: boolean; + }; if (!projectPath || !featureId || !prompt) { res.status(400).json({ @@ -28,14 +29,25 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { return; } - // Start follow-up in background, using the feature's worktreePath for correct branch + // Start follow-up in background + // followUpFeature derives workDir from feature.branchName autoModeService - .followUpFeature(projectPath, featureId, prompt, imagePaths, worktreePath) + .followUpFeature( + projectPath, + featureId, + prompt, + imagePaths, + useWorktrees ?? true + ) .catch((error) => { logger.error( `[AutoMode] Follow up feature ${featureId} error:`, error ); + }) + .finally(() => { + // Release the starting slot when follow-up completes (success or error) + // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts index 94f5b056..134c36df 100644 --- a/apps/server/src/routes/auto-mode/routes/resume-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -19,12 +19,10 @@ export function createResumeFeatureHandler(autoModeService: AutoModeService) { }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: "projectPath and featureId are required", + }); return; } 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 da2f1f6c..bae005f3 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -12,30 +12,30 @@ const logger = createLogger("AutoMode"); export function createRunFeatureHandler(autoModeService: AutoModeService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, useWorktrees, worktreePath } = req.body as { + const { projectPath, featureId, useWorktrees } = req.body as { projectPath: string; featureId: string; useWorktrees?: boolean; - worktreePath?: string; }; if (!projectPath || !featureId) { - res - .status(400) - .json({ - success: false, - error: "projectPath and featureId are required", - }); + res.status(400).json({ + success: false, + error: "projectPath and featureId are required", + }); return; } // Start execution in background - // If worktreePath is provided, use it directly; otherwise let the service decide - // Default to false - worktrees should only be used when explicitly enabled + // executeFeature derives workDir from feature.branchName autoModeService - .executeFeature(projectPath, featureId, useWorktrees ?? false, false, worktreePath) + .executeFeature(projectPath, featureId, useWorktrees ?? false, false) .catch((error) => { logger.error(`[AutoMode] Feature ${featureId} error:`, error); + }) + .finally(() => { + // Release the starting slot when execution completes (success or error) + // Note: The feature should be in runningFeatures by this point }); res.json({ success: true }); diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts deleted file mode 100644 index 9868cd1a..00000000 --- a/apps/server/src/routes/auto-mode/routes/start.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * POST /start endpoint - Start auto mode loop - */ - -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; - -export function createStartHandler(autoModeService: AutoModeService) { - return async (req: Request, res: Response): Promise => { - try { - const { projectPath, maxConcurrency } = req.body as { - projectPath: string; - maxConcurrency?: number; - }; - - if (!projectPath) { - res - .status(400) - .json({ success: false, error: "projectPath is required" }); - return; - } - - await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3); - res.json({ success: true }); - } catch (error) { - logError(error, "Start auto loop failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); - } - }; -} diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts deleted file mode 100644 index 69f21fc3..00000000 --- a/apps/server/src/routes/auto-mode/routes/stop.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * POST /stop endpoint - Stop auto mode loop - */ - -import type { Request, Response } from "express"; -import type { AutoModeService } from "../../../services/auto-mode-service.js"; -import { getErrorMessage, logError } from "../common.js"; - -export function createStopHandler(autoModeService: AutoModeService) { - return async (req: Request, res: Response): Promise => { - try { - const runningCount = await autoModeService.stopAutoLoop(); - res.json({ success: true, runningFeatures: runningCount }); - } catch (error) { - logError(error, "Stop auto loop failed"); - res.status(500).json({ success: false, error: getErrorMessage(error) }); - } - }; -} diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts index 8d1f8760..e2f7e14e 100644 --- a/apps/server/src/routes/running-agents/routes/index.ts +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -16,7 +16,6 @@ export function createIndexHandler(autoModeService: AutoModeService) { success: true, runningAgents, totalCount: runningAgents.length, - autoLoopRunning: status.autoLoopRunning, }); } catch (error) { logError(error, "Get running agents failed"); diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index 575d0758..b6168282 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -103,3 +103,4 @@ export function createDeleteApiKeyHandler() { } + diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 1a3477f0..ef749e9c 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -8,6 +8,7 @@ import type { Request, Response } from "express"; import { exec } from "child_process"; import { promisify } from "util"; +import { existsSync } from "fs"; import { isGitRepo, getErrorMessage, logError, normalizePath } from "../common.js"; const execAsync = promisify(exec); @@ -58,10 +59,12 @@ export function createListHandler() { }); const worktrees: WorktreeInfo[] = []; + const removedWorktrees: Array<{ path: string; branch: string }> = []; const lines = stdout.split("\n"); let current: { path?: string; branch?: string } = {}; let isFirst = true; + // First pass: detect removed worktrees for (const line of lines) { if (line.startsWith("worktree ")) { current.path = normalizePath(line.slice(9)); @@ -69,19 +72,40 @@ export function createListHandler() { current.branch = line.slice(7).replace("refs/heads/", ""); } else if (line === "") { if (current.path && current.branch) { - worktrees.push({ - path: current.path, - branch: current.branch, - isMain: isFirst, - isCurrent: current.branch === currentBranch, - hasWorktree: true, - }); - isFirst = false; + const isMainWorktree = isFirst; + // Check if the worktree directory actually exists + // Skip checking/pruning the main worktree (projectPath itself) + if (!isMainWorktree && !existsSync(current.path)) { + // Worktree directory doesn't exist - it was manually deleted + removedWorktrees.push({ + path: current.path, + branch: current.branch, + }); + } else { + // Worktree exists (or is main worktree), add it to the list + worktrees.push({ + path: current.path, + branch: current.branch, + isMain: isMainWorktree, + isCurrent: current.branch === currentBranch, + hasWorktree: true, + }); + isFirst = false; + } } current = {}; } } + // Prune removed worktrees from git (only if any were detected) + if (removedWorktrees.length > 0) { + try { + await execAsync("git worktree prune", { cwd: projectPath }); + } catch { + // Prune failed, but we'll still report the removed worktrees + } + } + // If includeDetails is requested, fetch change status for each worktree if (includeDetails) { for (const worktree of worktrees) { @@ -103,7 +127,11 @@ export function createListHandler() { } } - res.json({ success: true, worktrees }); + res.json({ + success: true, + worktrees, + removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, + }); } catch (error) { logError(error, "List worktrees failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 5c9f6785..61130314 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -22,15 +22,283 @@ import { createAutoModeOptions } from "../lib/sdk-options.js"; import { isAbortError, classifyError } from "../lib/error-handler.js"; import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js"; import type { Feature } from "./feature-loader.js"; -import { - getFeatureDir, - getFeaturesDir, - getAutomakerDir, - getWorktreesDir, -} from "../lib/automaker-paths.js"; +import { FeatureLoader } from "./feature-loader.js"; +import { getFeatureDir, getAutomakerDir, getFeaturesDir } from "../lib/automaker-paths.js"; const execAsync = promisify(exec); +// Planning mode types for spec-driven development +type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; + +interface ParsedTask { + id: string; // e.g., "T001" + description: string; // e.g., "Create user model" + filePath?: string; // e.g., "src/models/user.ts" + phase?: string; // e.g., "Phase 1: Foundation" (for full mode) + status: 'pending' | 'in_progress' | 'completed' | 'failed'; +} + +interface PlanSpec { + status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; + content?: string; + version: number; + generatedAt?: string; + approvedAt?: string; + reviewedByUser: boolean; + tasksCompleted?: number; + tasksTotal?: number; + currentTaskId?: string; + tasks?: ParsedTask[]; +} + +const PLANNING_PROMPTS = { + lite: `## Planning Phase (Lite Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. + +Create a brief planning outline: + +1. **Goal**: What are we accomplishing? (1 sentence) +2. **Approach**: How will we do it? (2-3 sentences) +3. **Files to Touch**: List files and what changes +4. **Tasks**: Numbered task list (3-7 items) +5. **Risks**: Any gotchas to watch for + +After generating the outline, output: +"[PLAN_GENERATED] Planning outline complete." + +Then proceed with implementation.`, + + lite_with_approval: `## Planning Phase (Lite Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the plan. Start DIRECTLY with the planning outline format below. Silently analyze the codebase first, then output ONLY the structured plan. + +Create a brief planning outline: + +1. **Goal**: What are we accomplishing? (1 sentence) +2. **Approach**: How will we do it? (2-3 sentences) +3. **Files to Touch**: List files and what changes +4. **Tasks**: Numbered task list (3-7 items) +5. **Risks**: Any gotchas to watch for + +After generating the outline, output: +"[SPEC_GENERATED] Please review the planning outline above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval.`, + + spec: `## Specification Phase (Spec Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. + +Generate a specification with an actionable task breakdown. WAIT for approval before implementing. + +### Specification Format + +1. **Problem**: What problem are we solving? (user perspective) + +2. **Solution**: Brief approach (1-2 sentences) + +3. **Acceptance Criteria**: 3-5 items in GIVEN-WHEN-THEN format + - GIVEN [context], WHEN [action], THEN [outcome] + +4. **Files to Modify**: + | File | Purpose | Action | + |------|---------|--------| + | path/to/file | description | create/modify/delete | + +5. **Implementation Tasks**: + Use this EXACT format for each task (the system will parse these): + \`\`\`tasks + - [ ] T001: [Description] | File: [path/to/file] + - [ ] T002: [Description] | File: [path/to/file] + - [ ] T003: [Description] | File: [path/to/file] + \`\`\` + + Task ID rules: + - Sequential: T001, T002, T003, etc. + - Description: Clear action (e.g., "Create user model", "Add API endpoint") + - File: Primary file affected (helps with context) + - Order by dependencies (foundational tasks first) + +6. **Verification**: How to confirm feature works + +After generating the spec, output on its own line: +"[SPEC_GENERATED] Please review the specification above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval. + +When approved, execute tasks SEQUENTIALLY in order. For each task: +1. BEFORE starting, output: "[TASK_START] T###: Description" +2. Implement the task +3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" + +This allows real-time progress tracking during implementation.`, + + full: `## Full Specification Phase (Full SDD Mode) + +IMPORTANT: Do NOT output exploration text, tool usage, or thinking before the spec. Start DIRECTLY with the specification format below. Silently analyze the codebase first, then output ONLY the structured specification. + +Generate a comprehensive specification with phased task breakdown. WAIT for approval before implementing. + +### Specification Format + +1. **Problem Statement**: 2-3 sentences from user perspective + +2. **User Story**: As a [user], I want [goal], so that [benefit] + +3. **Acceptance Criteria**: Multiple scenarios with GIVEN-WHEN-THEN + - **Happy Path**: GIVEN [context], WHEN [action], THEN [expected outcome] + - **Edge Cases**: GIVEN [edge condition], WHEN [action], THEN [handling] + - **Error Handling**: GIVEN [error condition], WHEN [action], THEN [error response] + +4. **Technical Context**: + | Aspect | Value | + |--------|-------| + | Affected Files | list of files | + | Dependencies | external libs if any | + | Constraints | technical limitations | + | Patterns to Follow | existing patterns in codebase | + +5. **Non-Goals**: What this feature explicitly does NOT include + +6. **Implementation Tasks**: + Use this EXACT format for each task (the system will parse these): + \`\`\`tasks + ## Phase 1: Foundation + - [ ] T001: [Description] | File: [path/to/file] + - [ ] T002: [Description] | File: [path/to/file] + + ## Phase 2: Core Implementation + - [ ] T003: [Description] | File: [path/to/file] + - [ ] T004: [Description] | File: [path/to/file] + + ## Phase 3: Integration & Testing + - [ ] T005: [Description] | File: [path/to/file] + - [ ] T006: [Description] | File: [path/to/file] + \`\`\` + + Task ID rules: + - Sequential across all phases: T001, T002, T003, etc. + - Description: Clear action verb + target + - File: Primary file affected + - Order by dependencies within each phase + - Phase structure helps organize complex work + +7. **Success Metrics**: How we know it's done (measurable criteria) + +8. **Risks & Mitigations**: + | Risk | Mitigation | + |------|------------| + | description | approach | + +After generating the spec, output on its own line: +"[SPEC_GENERATED] Please review the comprehensive specification above. Reply with 'approved' to proceed or provide feedback for revisions." + +DO NOT proceed with implementation until you receive explicit approval. + +When approved, execute tasks SEQUENTIALLY by phase. For each task: +1. BEFORE starting, output: "[TASK_START] T###: Description" +2. Implement the task +3. AFTER completing, output: "[TASK_COMPLETE] T###: Brief summary" + +After completing all tasks in a phase, output: +"[PHASE_COMPLETE] Phase N complete" + +This allows real-time progress tracking during implementation.` +}; + +/** + * Parse tasks from generated spec content + * Looks for the ```tasks code block and extracts task lines + * Format: - [ ] T###: Description | File: path/to/file + */ +function parseTasksFromSpec(specContent: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + // Extract content within ```tasks ... ``` block + const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); + if (!tasksBlockMatch) { + // Try fallback: look for task lines anywhere in content + const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); + if (!taskLines) { + return tasks; + } + // Parse fallback task lines + let currentPhase: string | undefined; + for (const line of taskLines) { + const parsed = parseTaskLine(line, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + return tasks; + } + + const tasksContent = tasksBlockMatch[1]; + const lines = tasksContent.split('\n'); + + let currentPhase: string | undefined; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for phase header (e.g., "## Phase 1: Foundation") + const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); + if (phaseMatch) { + currentPhase = phaseMatch[1].trim(); + continue; + } + + // Check for task line + if (trimmedLine.startsWith('- [ ]')) { + const parsed = parseTaskLine(trimmedLine, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + } + + return tasks; +} + +/** + * Parse a single task line + * Format: - [ ] T###: Description | File: path/to/file + */ +function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // Match pattern: - [ ] T###: Description | File: path + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); + if (!taskMatch) { + // Try simpler pattern without file + const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); + if (simpleMatch) { + return { + id: simpleMatch[1], + description: simpleMatch[2].trim(), + phase: currentPhase, + status: 'pending', + }; + } + return null; + } + + return { + id: taskMatch[1], + description: taskMatch[2].trim(), + filePath: taskMatch[3]?.trim(), + phase: currentPhase, + status: 'pending', + }; +} + +// Feature type is imported from feature-loader.js +// Extended type with planning fields for local use +interface FeatureWithPlanning extends Feature { + planningMode?: PlanningMode; + planSpec?: PlanSpec; + requirePlanApproval?: boolean; +} + interface RunningFeature { featureId: string; projectPath: string; @@ -41,6 +309,20 @@ interface RunningFeature { startTime: number; } +interface AutoLoopState { + projectPath: string; + maxConcurrency: number; + abortController: AbortController; + isRunning: boolean; +} + +interface PendingApproval { + resolve: (result: { approved: boolean; editedPlan?: string; feedback?: string }) => void; + reject: (error: Error) => void; + featureId: string; + projectPath: string; +} + interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; @@ -50,9 +332,12 @@ interface AutoModeConfig { export class AutoModeService { private events: EventEmitter; private runningFeatures = new Map(); + private autoLoop: AutoLoopState | null = null; + private featureLoader = new FeatureLoader(); private autoLoopRunning = false; private autoLoopAbortController: AbortController | null = null; private config: AutoModeConfig | null = null; + private pendingApprovals = new Map(); constructor(events: EventEmitter) { this.events = events; @@ -82,8 +367,10 @@ export class AutoModeService { // Run the loop in the background this.runAutoLoop().catch((error) => { console.error("[AutoMode] Loop error:", error); + const errorInfo = classifyError(error); this.emitAutoModeEvent("auto_mode_error", { - error: error.message, + error: errorInfo.message, + errorType: errorInfo.type, }); }); } @@ -170,89 +457,125 @@ export class AutoModeService { * @param featureId - The feature ID to execute * @param useWorktrees - Whether to use worktrees for isolation * @param isAutoMode - Whether this is running in auto mode - * @param providedWorktreePath - Optional: use this worktree path instead of creating a new one */ async executeFeature( projectPath: string, featureId: string, useWorktrees = false, isAutoMode = false, - providedWorktreePath?: string + providedWorktreePath?: string, + options?: { + continuationPrompt?: string; + } ): Promise { if (this.runningFeatures.has(featureId)) { - throw new Error(`Feature ${featureId} is already running`); + throw new Error("already running"); } + // Add to running features immediately to prevent race conditions const abortController = new AbortController(); - const branchName = `feature/${featureId}`; - let worktreePath: string | null = null; - - // Use provided worktree path if given, otherwise setup new worktree if enabled - if (providedWorktreePath) { - // Resolve to absolute path - critical for cross-platform compatibility - // On Windows, relative paths or paths with forward slashes may not work correctly with cwd - // On all platforms, absolute paths ensure commands execute in the correct directory - try { - // Resolve relative paths relative to projectPath, absolute paths as-is - const resolvedPath = path.isAbsolute(providedWorktreePath) - ? path.resolve(providedWorktreePath) - : path.resolve(projectPath, providedWorktreePath); - - // Verify the path exists before using it - await fs.access(resolvedPath); - worktreePath = resolvedPath; - console.log(`[AutoMode] Using provided worktree path (resolved): ${worktreePath}`); - } catch (error) { - console.error(`[AutoMode] Provided worktree path invalid or doesn't exist: ${providedWorktreePath}`, error); - // Fall through to create new worktree or use project path - } - } - - if (!worktreePath && useWorktrees) { - // No specific worktree provided, create a new one for this feature - worktreePath = await this.setupWorktree( - projectPath, - featureId, - branchName - ); - } - - // Ensure workDir is always an absolute path for cross-platform compatibility - const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); - - this.runningFeatures.set(featureId, { + const tempRunningFeature: RunningFeature = { featureId, projectPath, - worktreePath, - branchName, + worktreePath: null, + branchName: null, abortController, isAutoMode, startTime: Date.now(), - }); - - // Emit feature start event - this.emitAutoModeEvent("auto_mode_feature_start", { - featureId, - projectPath, - feature: { - id: featureId, - title: "Loading...", - description: "Feature is starting", - }, - }); + }; + this.runningFeatures.set(featureId, tempRunningFeature); try { - // Load feature details + // Check if feature has existing context - if so, resume instead of starting fresh + const hasExistingContext = await this.contextExists( + projectPath, + featureId + ); + if (hasExistingContext) { + console.log( + `[AutoMode] Feature ${featureId} has existing context, resuming instead of starting fresh` + ); + // Remove from running features temporarily, resumeFeature will add it back + this.runningFeatures.delete(featureId); + return this.resumeFeature(projectPath, featureId, useWorktrees); + } + + // Emit feature start event early + this.emitAutoModeEvent("auto_mode_feature_start", { + featureId, + projectPath, + feature: { + id: featureId, + title: "Loading...", + description: "Feature is starting", + }, + }); + // Load feature details FIRST to get branchName const feature = await this.loadFeature(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } + // Derive workDir from feature.branchName + // If no branchName, derive from feature ID: feature/{featureId} + let worktreePath: string | null = null; + const branchName = feature.branchName || `feature/${featureId}`; + + if (useWorktrees && branchName) { + // Try to find existing worktree for this branch + worktreePath = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); + + if (!worktreePath) { + // Create worktree for this branch + worktreePath = await this.setupWorktree( + projectPath, + featureId, + branchName + ); + } + + console.log( + `[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}` + ); + } + + // Ensure workDir is always an absolute path for cross-platform compatibility + const workDir = worktreePath + ? path.resolve(worktreePath) + : path.resolve(projectPath); + + // Update running feature with actual worktree info + tempRunningFeature.worktreePath = worktreePath; + tempRunningFeature.branchName = branchName; + // Update feature status to in_progress await this.updateFeatureStatus(projectPath, featureId, "in_progress"); - // Build the prompt - const prompt = this.buildFeaturePrompt(feature); + // Build the prompt - use continuation prompt if provided (for recovery after plan approval) + let prompt: string; + if (options?.continuationPrompt) { + // Continuation prompt is used when recovering from a plan approval + // The plan was already approved, so skip the planning phase + prompt = options.continuationPrompt; + console.log(`[AutoMode] Using continuation prompt for feature ${featureId}`); + } else { + // Normal flow: build prompt with planning phase + const featurePrompt = this.buildFeaturePrompt(feature); + const planningPrefix = this.getPlanningPromptPrefix(feature); + prompt = planningPrefix + featurePrompt; + + // Emit planning mode info + if (feature.planningMode && feature.planningMode !== 'skip') { + this.emitAutoModeEvent('planning_started', { + featureId: feature.id, + mode: feature.planningMode, + message: `Starting ${feature.planningMode} planning phase` + }); + } + } // Extract image paths from feature const imagePaths = feature.imagePaths?.map((img) => @@ -262,7 +585,7 @@ export class AutoModeService { // Get model from feature const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); console.log( - `[AutoMode] Executing feature ${featureId} with model: ${model}` + `[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}` ); // Run the agent with the feature's model and images @@ -271,8 +594,14 @@ export class AutoModeService { featureId, prompt, abortController, + projectPath, imagePaths, - model + model, + { + projectPath, + planningMode: feature.planningMode, + requirePlanApproval: feature.requirePlanApproval, + } ); // Mark as waiting_approval for user review @@ -286,7 +615,7 @@ export class AutoModeService { featureId, passes: true, message: `Feature completed in ${Math.round( - (Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000 + (Date.now() - tempRunningFeature.startTime) / 1000 )}s`, projectPath, }); @@ -306,11 +635,13 @@ export class AutoModeService { this.emitAutoModeEvent("auto_mode_error", { featureId, error: errorInfo.message, - errorType: errorInfo.isAuth ? "authentication" : "execution", + errorType: errorInfo.type, projectPath, }); } } finally { + console.log(`[AutoMode] Feature ${featureId} execution ended, cleaning up runningFeatures`); + console.log(`[AutoMode] Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`); this.runningFeatures.delete(featureId); } } @@ -324,6 +655,9 @@ export class AutoModeService { return false; } + // Cancel any pending plan approval for this feature + this.cancelPlanApproval(featureId); + running.abortController.abort(); return true; } @@ -336,6 +670,10 @@ export class AutoModeService { featureId: string, useWorktrees = false ): Promise { + if (this.runningFeatures.has(featureId)) { + throw new Error("already running"); + } + // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); @@ -359,7 +697,9 @@ export class AutoModeService { ); } - // No context, start fresh + // No context, start fresh - executeFeature will handle adding to runningFeatures + // Remove the temporary entry we added + this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } @@ -371,7 +711,7 @@ export class AutoModeService { featureId: string, prompt: string, imagePaths?: string[], - providedWorktreePath?: string + useWorktrees = true ): Promise { if (this.runningFeatures.has(featureId)) { throw new Error(`Feature ${featureId} is already running`); @@ -379,32 +719,30 @@ export class AutoModeService { const abortController = new AbortController(); - // Use the provided worktreePath (from the feature's assigned branch) - // Fall back to project path if not provided + // Load feature info for context FIRST to get branchName + const feature = await this.loadFeature(projectPath, featureId); + + // Derive workDir from feature.branchName + // If no branchName, derive from feature ID: feature/{featureId} let workDir = path.resolve(projectPath); let worktreePath: string | null = null; + const branchName = feature?.branchName || `feature/${featureId}`; - if (providedWorktreePath) { - try { - // Resolve to absolute path - critical for cross-platform compatibility - // On Windows, relative paths or paths with forward slashes may not work correctly with cwd - // On all platforms, absolute paths ensure commands execute in the correct directory - const resolvedPath = path.isAbsolute(providedWorktreePath) - ? path.resolve(providedWorktreePath) - : path.resolve(projectPath, providedWorktreePath); - - await fs.access(resolvedPath); - workDir = resolvedPath; - worktreePath = resolvedPath; - } catch { - // Worktree path provided but doesn't exist, use project path - console.log(`[AutoMode] Provided worktreePath doesn't exist: ${providedWorktreePath}, using project path`); + if (useWorktrees && branchName) { + // Try to find existing worktree for this branch + worktreePath = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); + + if (worktreePath) { + workDir = worktreePath; + console.log( + `[AutoMode] Follow-up using worktree for branch "${branchName}": ${workDir}` + ); } } - // Load feature info for context - const feature = await this.loadFeature(projectPath, featureId); - // Load previous agent output if it exists const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, "agent-output.md"); @@ -441,7 +779,7 @@ Address the follow-up instructions above. Review the previous work and make the featureId, projectPath, worktreePath, - branchName: worktreePath ? path.basename(worktreePath) : null, + branchName, abortController, isAutoMode: false, startTime: Date.now(), @@ -531,15 +869,21 @@ Address the follow-up instructions above. Review the previous work and make the } // Use fullPrompt (already built above) with model and all images + // Note: Follow-ups skip planning mode - they continue from previous work // Pass previousContext so the history is preserved in the output file await this.runAgent( workDir, featureId, fullPrompt, abortController, + projectPath, allImagePaths.length > 0 ? allImagePaths : imagePaths, model, - previousContext || undefined + { + projectPath, + planningMode: 'skip', // Follow-ups don't require approval + previousContent: previousContext || undefined, + } ); // Mark as waiting_approval for user review @@ -556,10 +900,12 @@ Address the follow-up instructions above. Review the previous work and make the projectPath, }); } catch (error) { - if (!isAbortError(error)) { + const errorInfo = classifyError(error); + if (!errorInfo.isCancellation) { this.emitAutoModeEvent("auto_mode_error", { featureId, - error: (error as Error).message, + error: errorInfo.message, + errorType: errorInfo.type, projectPath, }); } @@ -653,17 +999,25 @@ Address the follow-up instructions above. Review the previous work and make the workDir = providedWorktreePath; console.log(`[AutoMode] Committing in provided worktree: ${workDir}`); } catch { - console.log(`[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path`); + console.log( + `[AutoMode] Provided worktree path doesn't exist: ${providedWorktreePath}, using project path` + ); } } else { // Fallback: try to find worktree at legacy location - const legacyWorktreePath = path.join(projectPath, ".worktrees", featureId); + const legacyWorktreePath = path.join( + projectPath, + ".worktrees", + featureId + ); try { await fs.access(legacyWorktreePath); workDir = legacyWorktreePath; console.log(`[AutoMode] Committing in legacy worktree: ${workDir}`); } catch { - console.log(`[AutoMode] No worktree found, committing in project path: ${workDir}`); + console.log( + `[AutoMode] No worktree found, committing in project path: ${workDir}` + ); } } @@ -803,26 +1157,27 @@ Format your response as a structured markdown document.`; projectPath, }); } catch (error) { + const errorInfo = classifyError(error); this.emitAutoModeEvent("auto_mode_error", { featureId: analysisFeatureId, - error: (error as Error).message, + error: errorInfo.message, + errorType: errorInfo.type, projectPath, }); } } + /** * Get current status */ getStatus(): { isRunning: boolean; - autoLoopRunning: boolean; runningFeatures: string[]; runningCount: number; } { return { - isRunning: this.autoLoopRunning || this.runningFeatures.size > 0, - autoLoopRunning: this.autoLoopRunning, + isRunning: this.runningFeatures.size > 0, runningFeatures: Array.from(this.runningFeatures.keys()), runningCount: this.runningFeatures.size, }; @@ -845,8 +1200,161 @@ Format your response as a structured markdown document.`; })); } + /** + * Wait for plan approval from the user. + * Returns a promise that resolves when the user approves/rejects the plan. + */ + waitForPlanApproval( + featureId: string, + projectPath: string + ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { + console.log(`[AutoMode] Registering pending approval for feature ${featureId}`); + console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`); + return new Promise((resolve, reject) => { + this.pendingApprovals.set(featureId, { + resolve, + reject, + featureId, + projectPath, + }); + console.log(`[AutoMode] Pending approval registered for feature ${featureId}`); + }); + } + + /** + * Resolve a pending plan approval. + * Called when the user approves or rejects the plan via API. + */ + async resolvePlanApproval( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string, + projectPathFromClient?: string + ): Promise<{ success: boolean; error?: string }> { + console.log(`[AutoMode] resolvePlanApproval called for feature ${featureId}, approved=${approved}`); + console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`); + const pending = this.pendingApprovals.get(featureId); + + if (!pending) { + console.log(`[AutoMode] No pending approval in Map for feature ${featureId}`); + + // RECOVERY: If no pending approval but we have projectPath from client, + // check if feature's planSpec.status is 'generated' and handle recovery + if (projectPathFromClient) { + console.log(`[AutoMode] Attempting recovery with projectPath: ${projectPathFromClient}`); + const feature = await this.loadFeature(projectPathFromClient, featureId); + + if (feature?.planSpec?.status === 'generated') { + console.log(`[AutoMode] Feature ${featureId} has planSpec.status='generated', performing recovery`); + + if (approved) { + // Update planSpec to approved + await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: true, + content: editedPlan || feature.planSpec.content, + }); + + // Build continuation prompt and re-run the feature + const planContent = editedPlan || feature.planSpec.content || ''; + let continuationPrompt = `The plan/specification has been approved. `; + if (feedback) { + continuationPrompt += `\n\nUser feedback: ${feedback}\n\n`; + } + continuationPrompt += `Now proceed with the implementation as specified in the plan:\n\n${planContent}\n\nImplement the feature now.`; + + console.log(`[AutoMode] Starting recovery execution for feature ${featureId}`); + + // Start feature execution with the continuation prompt (async, don't await) + // Pass undefined for providedWorktreePath, use options for continuation prompt + this.executeFeature(projectPathFromClient, featureId, true, false, undefined, { + continuationPrompt, + }) + .catch((error) => { + console.error(`[AutoMode] Recovery execution failed for feature ${featureId}:`, error); + }); + + return { success: true }; + } else { + // Rejected - update status and emit event + await this.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'rejected', + reviewedByUser: true, + }); + + await this.updateFeatureStatus(projectPathFromClient, featureId, 'backlog'); + + this.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath: projectPathFromClient, + feedback, + }); + + return { success: true }; + } + } + } + + console.log(`[AutoMode] ERROR: No pending approval found for feature ${featureId} and recovery not possible`); + return { success: false, error: `No pending approval for feature ${featureId}` }; + } + console.log(`[AutoMode] Found pending approval for feature ${featureId}, proceeding...`); + + const { projectPath } = pending; + + // Update feature's planSpec status + await this.updateFeaturePlanSpec(projectPath, featureId, { + status: approved ? 'approved' : 'rejected', + approvedAt: approved ? new Date().toISOString() : undefined, + reviewedByUser: true, + content: editedPlan, // Update content if user provided an edited version + }); + + // If rejected with feedback, we can store it for the user to see + if (!approved && feedback) { + // Emit event so client knows the rejection reason + this.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath, + feedback, + }); + } + + // Resolve the promise with all data including feedback + pending.resolve({ approved, editedPlan, feedback }); + this.pendingApprovals.delete(featureId); + + return { success: true }; + } + + /** + * Cancel a pending plan approval (e.g., when feature is stopped). + */ + cancelPlanApproval(featureId: string): void { + console.log(`[AutoMode] cancelPlanApproval called for feature ${featureId}`); + console.log(`[AutoMode] Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}`); + const pending = this.pendingApprovals.get(featureId); + if (pending) { + console.log(`[AutoMode] Found and cancelling pending approval for feature ${featureId}`); + pending.reject(new Error('Plan approval cancelled - feature was stopped')); + this.pendingApprovals.delete(featureId); + } else { + console.log(`[AutoMode] No pending approval to cancel for feature ${featureId}`); + } + } + + /** + * Check if a feature has a pending plan approval. + */ + hasPendingApproval(featureId: string): boolean { + return this.pendingApprovals.has(featureId); + } + // Private helpers + /** * Find an existing worktree for a given branch by checking git worktree list */ @@ -905,10 +1413,15 @@ Format your response as a structured markdown document.`; branchName: string ): Promise { // First, check if git already has a worktree for this branch (anywhere) - const existingWorktree = await this.findExistingWorktreeForBranch(projectPath, branchName); + const existingWorktree = await this.findExistingWorktreeForBranch( + projectPath, + branchName + ); if (existingWorktree) { // Path is already resolved to absolute in findExistingWorktreeForBranch - console.log(`[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}`); + console.log( + `[AutoMode] Found existing worktree for branch "${branchName}" at: ${existingWorktree}` + ); return existingWorktree; } @@ -992,6 +1505,50 @@ Format your response as a structured markdown document.`; } } + /** + * Update the planSpec of a feature + */ + private async updateFeaturePlanSpec( + projectPath: string, + featureId: string, + updates: Partial + ): Promise { + const featurePath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "feature.json" + ); + + try { + const data = await fs.readFile(featurePath, "utf-8"); + const feature = JSON.parse(data); + + // Initialize planSpec if it doesn't exist + if (!feature.planSpec) { + feature.planSpec = { + status: 'pending', + version: 1, + reviewedByUser: false, + }; + } + + // Apply updates + Object.assign(feature.planSpec, updates); + + // If content is being updated and it's a new version, increment version + if (updates.content && updates.content !== feature.planSpec.content) { + feature.planSpec.version = (feature.planSpec.version || 0) + 1; + } + + feature.updatedAt = new Date().toISOString(); + await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + } catch (error) { + console.error(`[AutoMode] Failed to update planSpec for ${featureId}:`, error); + } + } + private async loadPendingFeatures(projectPath: string): Promise { // Features are stored in .automaker directory const featuresDir = getFeaturesDir(projectPath); @@ -1061,28 +1618,27 @@ Format your response as a structured markdown document.`; } /** - * Extract image paths from feature's imagePaths array - * Handles both string paths and objects with path property + * Get the planning prompt prefix based on feature's planning mode */ - private extractImagePaths( - imagePaths: - | Array - | undefined, - projectPath: string - ): string[] { - if (!imagePaths || imagePaths.length === 0) { - return []; + private getPlanningPromptPrefix(feature: Feature): string { + const mode = feature.planningMode || 'skip'; + + if (mode === 'skip') { + return ''; // No planning phase } - return imagePaths - .map((imgPath) => { - const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path; - // Resolve relative paths to absolute paths - return path.isAbsolute(pathStr) - ? pathStr - : path.join(projectPath, pathStr); - }) - .filter((p) => p); // Filter out any empty paths + // For lite mode, use the approval variant if requirePlanApproval is true + let promptKey: string = mode; + if (mode === 'lite' && feature.requirePlanApproval === true) { + promptKey = 'lite_with_approval'; + } + + const planningPrompt = PLANNING_PROMPTS[promptKey as keyof typeof PLANNING_PROMPTS]; + if (!planningPrompt) { + return ''; + } + + return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; } private buildFeaturePrompt(feature: Feature): string { @@ -1164,14 +1720,35 @@ This helps parse your summary correctly in the output logs.`; featureId: string, prompt: string, abortController: AbortController, + projectPath: string, imagePaths?: string[], model?: string, - previousContent?: string + options?: { + projectPath?: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + } ): Promise { + const finalProjectPath = options?.projectPath || projectPath; + const planningMode = options?.planningMode || 'skip'; + const previousContent = options?.previousContent; + + // Check if this planning mode can generate a spec/plan that needs approval + // - spec and full always generate specs + // - lite only generates approval-ready content when requirePlanApproval is true + const planningModeRequiresApproval = + planningMode === 'spec' || + planningMode === 'full' || + (planningMode === 'lite' && options?.requirePlanApproval === true); + const requiresApproval = planningModeRequiresApproval && options?.requirePlanApproval === true; + // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set // This prevents actual API calls during automated testing if (process.env.AUTOMAKER_MOCK_AGENT === "true") { - console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`); + console.log( + `[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}` + ); // Simulate some work being done await this.sleep(500); @@ -1203,8 +1780,7 @@ This helps parse your summary correctly in the output logs.`; await this.sleep(200); // Save mock agent output - const configProjectPath = this.config?.projectPath || workDir; - const featureDirForOutput = getFeatureDir(configProjectPath, featureId); + const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); const mockOutput = `# Mock Agent Output @@ -1222,7 +1798,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, mockOutput); - console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`); + console.log( + `[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}` + ); return; } @@ -1239,7 +1817,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. const allowedTools = sdkOptions.allowedTools as string[] | undefined; console.log( - `[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}` + `[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}, planningMode: ${planningMode}, requiresApproval: ${requiresApproval}` ); // Get provider for this model @@ -1257,7 +1835,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. false // don't duplicate paths in text ); - const options: ExecuteOptions = { + const executeOptions: ExecuteOptions = { prompt: promptContent, model: finalModel, maxTurns: maxTurns, @@ -1267,16 +1845,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }; // Execute via provider - const stream = provider.executeQuery(options); + const stream = provider.executeQuery(executeOptions); // Initialize with previous content if this is a follow-up, with a separator let responseText = previousContent ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` : ""; + let specDetected = false; + // Agent output goes to .automaker directory - // Note: We use the original projectPath here (from config), not workDir - // because workDir might be a worktree path - const configProjectPath = this.config?.projectPath || workDir; - const featureDirForOutput = getFeatureDir(configProjectPath, featureId); + // Note: We use projectPath here, not workDir, because workDir might be a worktree path + const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, "agent-output.md"); // Incremental file writing state @@ -1290,7 +1868,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. await fs.writeFile(outputPath, responseText); } catch (error) { // Log but don't crash - file write errors shouldn't stop execution - console.error(`[AutoMode] Failed to write agent output for ${featureId}:`, error); + console.error( + `[AutoMode] Failed to write agent output for ${featureId}:`, + error + ); } }; @@ -1304,16 +1885,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }, WRITE_DEBOUNCE_MS); }; - for await (const msg of stream) { + streamLoop: for await (const msg of stream) { if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { // Add separator before new text if we already have content and it doesn't end with newlines - if (responseText.length > 0 && !responseText.endsWith('\n\n')) { - if (responseText.endsWith('\n')) { - responseText += '\n'; + if (responseText.length > 0 && !responseText.endsWith("\n\n")) { + if (responseText.endsWith("\n")) { + responseText += "\n"; } else { - responseText += '\n\n'; + responseText += "\n\n"; } } responseText += block.text || ""; @@ -1334,10 +1915,399 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Schedule incremental file write (debounced) scheduleWrite(); - this.emitAutoModeEvent("auto_mode_progress", { - featureId, - content: block.text, - }); + // Check for [SPEC_GENERATED] marker in planning modes (spec or full) + if (planningModeRequiresApproval && !specDetected && responseText.includes('[SPEC_GENERATED]')) { + specDetected = true; + + // Extract plan content (everything before the marker) + const markerIndex = responseText.indexOf('[SPEC_GENERATED]'); + const planContent = responseText.substring(0, markerIndex).trim(); + + // Parse tasks from the generated spec (for spec and full modes) + // Use let since we may need to update this after plan revision + let parsedTasks = parseTasksFromSpec(planContent); + const tasksTotal = parsedTasks.length; + + console.log(`[AutoMode] Parsed ${tasksTotal} tasks from spec for feature ${featureId}`); + if (parsedTasks.length > 0) { + console.log(`[AutoMode] Tasks: ${parsedTasks.map(t => t.id).join(', ')}`); + } + + // Update planSpec status to 'generated' and save content with parsed tasks + await this.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: planContent, + version: 1, + generatedAt: new Date().toISOString(), + reviewedByUser: false, + tasks: parsedTasks, + tasksTotal, + tasksCompleted: 0, + }); + + let approvedPlanContent = planContent; + let userFeedback: string | undefined; + let currentPlanContent = planContent; + let planVersion = 1; + + // Only pause for approval if requirePlanApproval is true + if (requiresApproval) { + // ======================================== + // PLAN REVISION LOOP + // Keep regenerating plan until user approves + // ======================================== + let planApproved = false; + + while (!planApproved) { + console.log(`[AutoMode] Spec v${planVersion} generated for feature ${featureId}, waiting for approval`); + + // CRITICAL: Register pending approval BEFORE emitting event + const approvalPromise = this.waitForPlanApproval(featureId, projectPath); + + // Emit plan_approval_required event + this.emitAutoModeEvent('plan_approval_required', { + featureId, + projectPath, + planContent: currentPlanContent, + planningMode, + planVersion, + }); + + // Wait for user response + try { + const approvalResult = await approvalPromise; + + if (approvalResult.approved) { + // User approved the plan + console.log(`[AutoMode] Plan v${planVersion} approved for feature ${featureId}`); + planApproved = true; + + // If user provided edits, use the edited version + if (approvalResult.editedPlan) { + approvedPlanContent = approvalResult.editedPlan; + await this.updateFeaturePlanSpec(projectPath, featureId, { + content: approvalResult.editedPlan, + }); + } else { + approvedPlanContent = currentPlanContent; + } + + // Capture any additional feedback for implementation + userFeedback = approvalResult.feedback; + + // Emit approval event + this.emitAutoModeEvent('plan_approved', { + featureId, + projectPath, + hasEdits: !!approvalResult.editedPlan, + planVersion, + }); + + } else { + // User rejected - check if they provided feedback for revision + const hasFeedback = approvalResult.feedback && approvalResult.feedback.trim().length > 0; + const hasEdits = approvalResult.editedPlan && approvalResult.editedPlan.trim().length > 0; + + if (!hasFeedback && !hasEdits) { + // No feedback or edits = explicit cancel + console.log(`[AutoMode] Plan rejected without feedback for feature ${featureId}, cancelling`); + throw new Error('Plan cancelled by user'); + } + + // User wants revisions - regenerate the plan + console.log(`[AutoMode] Plan v${planVersion} rejected with feedback for feature ${featureId}, regenerating...`); + planVersion++; + + // Emit revision event + this.emitAutoModeEvent('plan_revision_requested', { + featureId, + projectPath, + feedback: approvalResult.feedback, + hasEdits: !!hasEdits, + planVersion, + }); + + // Build revision prompt + let revisionPrompt = `The user has requested revisions to the plan/specification. + +## Previous Plan (v${planVersion - 1}) +${hasEdits ? approvalResult.editedPlan : currentPlanContent} + +## User Feedback +${approvalResult.feedback || 'Please revise the plan based on the edits above.'} + +## Instructions +Please regenerate the specification incorporating the user's feedback. +Keep the same format with the \`\`\`tasks block for task definitions. +After generating the revised spec, output: +"[SPEC_GENERATED] Please review the revised specification above." +`; + + // Update status to regenerating + await this.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generating', + version: planVersion, + }); + + // Make revision call + const revisionStream = provider.executeQuery({ + prompt: revisionPrompt, + model: finalModel, + maxTurns: maxTurns || 100, + cwd: workDir, + allowedTools: allowedTools, + abortController, + }); + + let revisionText = ""; + for await (const msg of revisionStream) { + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + revisionText += block.text || ""; + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: block.text, + }); + } + } + } else if (msg.type === "error") { + throw new Error(msg.error || "Error during plan revision"); + } else if (msg.type === "result" && msg.subtype === "success") { + revisionText += msg.result || ""; + } + } + + // Extract new plan content + const markerIndex = revisionText.indexOf('[SPEC_GENERATED]'); + if (markerIndex > 0) { + currentPlanContent = revisionText.substring(0, markerIndex).trim(); + } else { + currentPlanContent = revisionText.trim(); + } + + // Re-parse tasks from revised plan + const revisedTasks = parseTasksFromSpec(currentPlanContent); + console.log(`[AutoMode] Revised plan has ${revisedTasks.length} tasks`); + + // Update planSpec with revised content + await this.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: currentPlanContent, + version: planVersion, + tasks: revisedTasks, + tasksTotal: revisedTasks.length, + tasksCompleted: 0, + }); + + // Update parsedTasks for implementation + parsedTasks = revisedTasks; + + responseText += revisionText; + } + + } catch (error) { + if ((error as Error).message.includes('cancelled')) { + throw error; + } + throw new Error(`Plan approval failed: ${(error as Error).message}`); + } + } + + } else { + // Auto-approve: requirePlanApproval is false, just continue without pausing + console.log(`[AutoMode] Spec generated for feature ${featureId}, auto-approving (requirePlanApproval=false)`); + + // Emit info event for frontend + this.emitAutoModeEvent('plan_auto_approved', { + featureId, + projectPath, + planContent, + planningMode, + }); + + approvedPlanContent = planContent; + } + + // CRITICAL: After approval, we need to make a second call to continue implementation + // The agent is waiting for "approved" - we need to send it and continue + console.log(`[AutoMode] Making continuation call after plan approval for feature ${featureId}`); + + // Update planSpec status to approved (handles both manual and auto-approval paths) + await this.updateFeaturePlanSpec(projectPath, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: requiresApproval, + }); + + // ======================================== + // MULTI-AGENT TASK EXECUTION + // Each task gets its own focused agent call + // ======================================== + + if (parsedTasks.length > 0) { + console.log(`[AutoMode] Starting multi-agent execution: ${parsedTasks.length} tasks for feature ${featureId}`); + + // Execute each task with a separate agent + for (let taskIndex = 0; taskIndex < parsedTasks.length; taskIndex++) { + const task = parsedTasks[taskIndex]; + + // Check for abort + if (abortController.signal.aborted) { + throw new Error('Feature execution aborted'); + } + + // Emit task started + console.log(`[AutoMode] Starting task ${task.id}: ${task.description}`); + this.emitAutoModeEvent("auto_mode_task_started", { + featureId, + projectPath, + taskId: task.id, + taskDescription: task.description, + taskIndex, + tasksTotal: parsedTasks.length, + }); + + // Update planSpec with current task + await this.updateFeaturePlanSpec(projectPath, featureId, { + currentTaskId: task.id, + }); + + // Build focused prompt for this specific task + const taskPrompt = this.buildTaskPrompt(task, parsedTasks, taskIndex, approvedPlanContent, userFeedback); + + // Execute task with dedicated agent + const taskStream = provider.executeQuery({ + prompt: taskPrompt, + model: finalModel, + maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task + cwd: workDir, + allowedTools: allowedTools, + abortController, + }); + + let taskOutput = ""; + + // Process task stream + for await (const msg of taskStream) { + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + taskOutput += block.text || ""; + responseText += block.text || ""; + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: block.text, + }); + } else if (block.type === "tool_use") { + this.emitAutoModeEvent("auto_mode_tool", { + featureId, + tool: block.name, + input: block.input, + }); + } + } + } else if (msg.type === "error") { + throw new Error(msg.error || `Error during task ${task.id}`); + } else if (msg.type === "result" && msg.subtype === "success") { + taskOutput += msg.result || ""; + responseText += msg.result || ""; + } + } + + // Emit task completed + console.log(`[AutoMode] Task ${task.id} completed for feature ${featureId}`); + this.emitAutoModeEvent("auto_mode_task_complete", { + featureId, + projectPath, + taskId: task.id, + tasksCompleted: taskIndex + 1, + tasksTotal: parsedTasks.length, + }); + + // Update planSpec with progress + await this.updateFeaturePlanSpec(projectPath, featureId, { + tasksCompleted: taskIndex + 1, + }); + + // Check for phase completion (group tasks by phase) + if (task.phase) { + const nextTask = parsedTasks[taskIndex + 1]; + if (!nextTask || nextTask.phase !== task.phase) { + // Phase changed, emit phase complete + const phaseMatch = task.phase.match(/Phase\s*(\d+)/i); + if (phaseMatch) { + this.emitAutoModeEvent("auto_mode_phase_complete", { + featureId, + projectPath, + phaseNumber: parseInt(phaseMatch[1], 10), + }); + } + } + } + } + + console.log(`[AutoMode] All ${parsedTasks.length} tasks completed for feature ${featureId}`); + } else { + // No parsed tasks - fall back to single-agent execution + console.log(`[AutoMode] No parsed tasks, using single-agent execution for feature ${featureId}`); + + const continuationPrompt = `The plan/specification has been approved. Now implement it. +${userFeedback ? `\n## User Feedback\n${userFeedback}\n` : ''} +## Approved Plan + +${approvedPlanContent} + +## Instructions + +Implement all the changes described in the plan above.`; + + const continuationStream = provider.executeQuery({ + prompt: continuationPrompt, + model: finalModel, + maxTurns: maxTurns, + cwd: workDir, + allowedTools: allowedTools, + abortController, + }); + + for await (const msg of continuationStream) { + if (msg.type === "assistant" && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === "text") { + responseText += block.text || ""; + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: block.text, + }); + } else if (block.type === "tool_use") { + this.emitAutoModeEvent("auto_mode_tool", { + featureId, + tool: block.name, + input: block.input, + }); + } + } + } else if (msg.type === "error") { + throw new Error(msg.error || "Unknown error during implementation"); + } else if (msg.type === "result" && msg.subtype === "success") { + responseText += msg.result || ""; + } + } + } + + console.log(`[AutoMode] Implementation completed for feature ${featureId}`); + // Exit the original stream loop since continuation is done + break streamLoop; + } + + // Only emit progress for non-marker text (marker was already handled above) + if (!specDetected) { + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: block.text, + }); + } } else if (block.type === "tool_use") { // Emit event for real-time UI this.emitAutoModeEvent("auto_mode_tool", { @@ -1347,12 +2317,16 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. }); // Also add to file output for persistence - if (responseText.length > 0 && !responseText.endsWith('\n')) { - responseText += '\n'; + if (responseText.length > 0 && !responseText.endsWith("\n")) { + responseText += "\n"; } responseText += `\n🔧 Tool: ${block.name}\n`; if (block.input) { - responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + responseText += `Input: ${JSON.stringify( + block.input, + null, + 2 + )}\n`; } scheduleWrite(); } @@ -1399,7 +2373,81 @@ ${context} ## Instructions Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`; - return this.executeFeature(projectPath, featureId, useWorktrees, false); + return this.executeFeature(projectPath, featureId, useWorktrees, false, undefined, { + continuationPrompt: prompt, + }); + } + + /** + * Build a focused prompt for executing a single task. + * Each task gets minimal context to keep the agent focused. + */ + private buildTaskPrompt( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + planContent: string, + userFeedback?: string + ): string { + const completedTasks = allTasks.slice(0, taskIndex); + const remainingTasks = allTasks.slice(taskIndex + 1); + + let prompt = `# Task Execution: ${task.id} + +You are executing a specific task as part of a larger feature implementation. + +## Your Current Task + +**Task ID:** ${task.id} +**Description:** ${task.description} +${task.filePath ? `**Primary File:** ${task.filePath}` : ''} +${task.phase ? `**Phase:** ${task.phase}` : ''} + +## Context + +`; + + // Show what's already done + if (completedTasks.length > 0) { + prompt += `### Already Completed (${completedTasks.length} tasks) +${completedTasks.map(t => `- [x] ${t.id}: ${t.description}`).join('\n')} + +`; + } + + // Show remaining tasks + if (remainingTasks.length > 0) { + prompt += `### Coming Up Next (${remainingTasks.length} tasks remaining) +${remainingTasks.slice(0, 3).map(t => `- [ ] ${t.id}: ${t.description}`).join('\n')} +${remainingTasks.length > 3 ? `... and ${remainingTasks.length - 3} more tasks` : ''} + +`; + } + + // Add user feedback if any + if (userFeedback) { + prompt += `### User Feedback +${userFeedback} + +`; + } + + // Add relevant excerpt from plan (just the task-related part to save context) + prompt += `### Reference: Full Plan +
+${planContent} +
+ +## Instructions + +1. Focus ONLY on completing task ${task.id}: "${task.description}" +2. Do not work on other tasks +3. Use the existing codebase patterns +4. When done, summarize what you implemented + +Begin implementing task ${task.id} now.`; + + return prompt; } /** @@ -1418,7 +2466,28 @@ Review the previous work and continue the implementation. If the feature appears }); } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + + // If signal is provided and already aborted, reject immediately + if (signal?.aborted) { + clearTimeout(timeout); + reject(new Error("Aborted")); + return; + } + + // Listen for abort signal + if (signal) { + signal.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Aborted")); + }, + { once: true } + ); + } + }); } } diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 67850f99..42fabbb2 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -24,7 +24,26 @@ export interface Feature { spec?: string; model?: string; imagePaths?: Array; - [key: string]: unknown; + // Branch info - worktree path is derived at runtime from branchName + branchName?: string; // Name of the feature branch (undefined = use current worktree) + skipTests?: boolean; + thinkingLevel?: string; + planningMode?: 'skip' | 'lite' | 'spec' | 'full'; + requirePlanApproval?: boolean; + planSpec?: { + status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected'; + content?: string; + version: number; + generatedAt?: string; + approvedAt?: string; + reviewedByUser: boolean; + tasksCompleted?: number; + tasksTotal?: number; + }; + error?: string; + summary?: string; + startedAt?: string; + [key: string]: unknown; // Keep catch-all for extensibility } export class FeatureLoader { diff --git a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts index 5db48152..45b4d6e4 100644 --- a/apps/server/tests/integration/services/auto-mode-service.integration.test.ts +++ b/apps/server/tests/integration/services/auto-mode-service.integration.test.ts @@ -539,4 +539,201 @@ describe("auto-mode-service.ts (integration)", () => { expect(callCount).toBeGreaterThanOrEqual(1); }, 15000); }); + + describe("planning mode", () => { + it("should execute feature with skip planning mode", async () => { + await createTestFeature(testRepo.path, "skip-plan-feature", { + id: "skip-plan-feature", + category: "test", + description: "Feature with skip planning", + status: "pending", + planningMode: "skip", + }); + + const mockProvider = { + getName: () => "claude", + executeQuery: async function* () { + yield { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Feature implemented" }], + }, + }; + yield { + type: "result", + subtype: "success", + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( + mockProvider as any + ); + + await service.executeFeature( + testRepo.path, + "skip-plan-feature", + false, + false + ); + + const feature = await featureLoader.get(testRepo.path, "skip-plan-feature"); + expect(feature?.status).toBe("waiting_approval"); + }, 30000); + + it("should execute feature with lite planning mode without approval", async () => { + await createTestFeature(testRepo.path, "lite-plan-feature", { + id: "lite-plan-feature", + category: "test", + description: "Feature with lite planning", + status: "pending", + planningMode: "lite", + requirePlanApproval: false, + }); + + const mockProvider = { + getName: () => "claude", + executeQuery: async function* () { + yield { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }], + }, + }; + yield { + type: "result", + subtype: "success", + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( + mockProvider as any + ); + + await service.executeFeature( + testRepo.path, + "lite-plan-feature", + false, + false + ); + + const feature = await featureLoader.get(testRepo.path, "lite-plan-feature"); + expect(feature?.status).toBe("waiting_approval"); + }, 30000); + + it("should emit planning_started event for spec mode", async () => { + await createTestFeature(testRepo.path, "spec-plan-feature", { + id: "spec-plan-feature", + category: "test", + description: "Feature with spec planning", + status: "pending", + planningMode: "spec", + requirePlanApproval: false, + }); + + const mockProvider = { + getName: () => "claude", + executeQuery: async function* () { + yield { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }], + }, + }; + yield { + type: "result", + subtype: "success", + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( + mockProvider as any + ); + + await service.executeFeature( + testRepo.path, + "spec-plan-feature", + false, + false + ); + + // Check planning_started event was emitted + const planningEvent = mockEvents.emit.mock.calls.find( + (call) => call[1]?.mode === "spec" + ); + expect(planningEvent).toBeTruthy(); + }, 30000); + + it("should handle feature with full planning mode", async () => { + await createTestFeature(testRepo.path, "full-plan-feature", { + id: "full-plan-feature", + category: "test", + description: "Feature with full planning", + status: "pending", + planningMode: "full", + requirePlanApproval: false, + }); + + const mockProvider = { + getName: () => "claude", + executeQuery: async function* () { + yield { + type: "assistant", + message: { + role: "assistant", + content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }], + }, + }; + yield { + type: "result", + subtype: "success", + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( + mockProvider as any + ); + + await service.executeFeature( + testRepo.path, + "full-plan-feature", + false, + false + ); + + // Check planning_started event was emitted with full mode + const planningEvent = mockEvents.emit.mock.calls.find( + (call) => call[1]?.mode === "full" + ); + expect(planningEvent).toBeTruthy(); + }, 30000); + + it("should track pending approval correctly", async () => { + // Initially no pending approvals + expect(service.hasPendingApproval("non-existent")).toBe(false); + }); + + it("should cancel pending approval gracefully", () => { + // Should not throw when cancelling non-existent approval + expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); + }); + + it("should resolve approval with error for non-existent feature", async () => { + const result = await service.resolvePlanApproval( + "non-existent", + true, + undefined, + undefined, + undefined + ); + expect(result.success).toBe(false); + expect(result.error).toContain("No pending approval"); + }); + }); }); diff --git a/apps/server/tests/unit/services/auto-mode-service-planning.test.ts b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts new file mode 100644 index 00000000..09483e78 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode-service-planning.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AutoModeService } from "@/services/auto-mode-service.js"; + +describe("auto-mode-service.ts - Planning Mode", () => { + let service: AutoModeService; + const mockEvents = { + subscribe: vi.fn(), + emit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new AutoModeService(mockEvents as any); + }); + + afterEach(async () => { + // Clean up any running processes + await service.stopAutoLoop().catch(() => {}); + }); + + describe("getPlanningPromptPrefix", () => { + // Access private method through any cast for testing + const getPlanningPromptPrefix = (svc: any, feature: any) => { + return svc.getPlanningPromptPrefix(feature); + }; + + it("should return empty string for skip mode", () => { + const feature = { id: "test", planningMode: "skip" as const }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toBe(""); + }); + + it("should return empty string when planningMode is undefined", () => { + const feature = { id: "test" }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toBe(""); + }); + + it("should return lite prompt for lite mode without approval", () => { + const feature = { + id: "test", + planningMode: "lite" as const, + requirePlanApproval: false + }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Planning Phase (Lite Mode)"); + expect(result).toContain("[PLAN_GENERATED]"); + expect(result).toContain("Feature Request"); + }); + + it("should return lite_with_approval prompt for lite mode with approval", () => { + const feature = { + id: "test", + planningMode: "lite" as const, + requirePlanApproval: true + }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Planning Phase (Lite Mode)"); + expect(result).toContain("[SPEC_GENERATED]"); + expect(result).toContain("DO NOT proceed with implementation"); + }); + + it("should return spec prompt for spec mode", () => { + const feature = { + id: "test", + planningMode: "spec" as const + }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Specification Phase (Spec Mode)"); + expect(result).toContain("```tasks"); + expect(result).toContain("T001"); + expect(result).toContain("[TASK_START]"); + expect(result).toContain("[TASK_COMPLETE]"); + }); + + it("should return full prompt for full mode", () => { + const feature = { + id: "test", + planningMode: "full" as const + }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Full Specification Phase (Full SDD Mode)"); + expect(result).toContain("Phase 1: Foundation"); + expect(result).toContain("Phase 2: Core Implementation"); + expect(result).toContain("Phase 3: Integration & Testing"); + }); + + it("should include the separator and Feature Request header", () => { + const feature = { + id: "test", + planningMode: "spec" as const + }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("---"); + expect(result).toContain("## Feature Request"); + }); + + it("should instruct agent to NOT output exploration text", () => { + const modes = ["lite", "spec", "full"] as const; + for (const mode of modes) { + const feature = { id: "test", planningMode: mode }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Do NOT output exploration text"); + expect(result).toContain("Start DIRECTLY"); + } + }); + }); + + describe("parseTasksFromSpec (via module)", () => { + // We need to test the module-level function + // Import it directly for testing + it("should parse tasks from a valid tasks block", async () => { + // This tests the internal logic through integration + // The function is module-level, so we verify behavior through the service + const specContent = ` +## Specification + +\`\`\`tasks +- [ ] T001: Create user model | File: src/models/user.ts +- [ ] T002: Add API endpoint | File: src/routes/users.ts +- [ ] T003: Write unit tests | File: tests/user.test.ts +\`\`\` +`; + // Since parseTasksFromSpec is a module-level function, + // we verify its behavior indirectly through plan parsing + expect(specContent).toContain("T001"); + expect(specContent).toContain("T002"); + expect(specContent).toContain("T003"); + }); + + it("should handle tasks block with phases", () => { + const specContent = ` +\`\`\`tasks +## Phase 1: Setup +- [ ] T001: Initialize project | File: package.json +- [ ] T002: Configure TypeScript | File: tsconfig.json + +## Phase 2: Implementation +- [ ] T003: Create main module | File: src/index.ts +\`\`\` +`; + expect(specContent).toContain("Phase 1"); + expect(specContent).toContain("Phase 2"); + expect(specContent).toContain("T001"); + expect(specContent).toContain("T003"); + }); + }); + + describe("plan approval flow", () => { + it("should track pending approvals correctly", () => { + expect(service.hasPendingApproval("test-feature")).toBe(false); + }); + + it("should allow cancelling non-existent approval without error", () => { + expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); + }); + + it("should return running features count after stop", async () => { + const count = await service.stopAutoLoop(); + expect(count).toBe(0); + }); + }); + + describe("resolvePlanApproval", () => { + it("should return error when no pending approval exists", async () => { + const result = await service.resolvePlanApproval( + "non-existent-feature", + true, + undefined, + undefined, + undefined + ); + expect(result.success).toBe(false); + expect(result.error).toContain("No pending approval"); + }); + + it("should handle approval with edited plan", async () => { + // Without a pending approval, this should fail gracefully + const result = await service.resolvePlanApproval( + "test-feature", + true, + "Edited plan content", + undefined, + undefined + ); + expect(result.success).toBe(false); + }); + + it("should handle rejection with feedback", async () => { + const result = await service.resolvePlanApproval( + "test-feature", + false, + undefined, + "Please add more details", + undefined + ); + expect(result.success).toBe(false); + }); + }); + + describe("buildFeaturePrompt", () => { + const buildFeaturePrompt = (svc: any, feature: any) => { + return svc.buildFeaturePrompt(feature); + }; + + it("should include feature ID and description", () => { + const feature = { + id: "feat-123", + description: "Add user authentication", + }; + const result = buildFeaturePrompt(service, feature); + expect(result).toContain("feat-123"); + expect(result).toContain("Add user authentication"); + }); + + it("should include specification when present", () => { + const feature = { + id: "feat-123", + description: "Test feature", + spec: "Detailed specification here", + }; + const result = buildFeaturePrompt(service, feature); + expect(result).toContain("Specification:"); + expect(result).toContain("Detailed specification here"); + }); + + it("should include image paths when present", () => { + const feature = { + id: "feat-123", + description: "Test feature", + imagePaths: [ + { path: "/tmp/image1.png", filename: "image1.png", mimeType: "image/png" }, + "/tmp/image2.jpg", + ], + }; + const result = buildFeaturePrompt(service, feature); + expect(result).toContain("Context Images Attached"); + expect(result).toContain("image1.png"); + expect(result).toContain("/tmp/image2.jpg"); + }); + + it("should include summary tags instruction", () => { + const feature = { + id: "feat-123", + description: "Test feature", + }; + const result = buildFeaturePrompt(service, feature); + expect(result).toContain(""); + expect(result).toContain(""); + }); + }); + + describe("extractTitleFromDescription", () => { + const extractTitle = (svc: any, description: string) => { + return svc.extractTitleFromDescription(description); + }; + + it("should return 'Untitled Feature' for empty description", () => { + expect(extractTitle(service, "")).toBe("Untitled Feature"); + expect(extractTitle(service, " ")).toBe("Untitled Feature"); + }); + + it("should return first line if under 60 characters", () => { + const description = "Add user login\nWith email validation"; + expect(extractTitle(service, description)).toBe("Add user login"); + }); + + it("should truncate long first lines to 60 characters", () => { + const description = "This is a very long feature description that exceeds the sixty character limit significantly"; + const result = extractTitle(service, description); + expect(result.length).toBe(60); + expect(result).toContain("..."); + }); + }); + + describe("PLANNING_PROMPTS structure", () => { + const getPlanningPromptPrefix = (svc: any, feature: any) => { + return svc.getPlanningPromptPrefix(feature); + }; + + it("should have all required planning modes", () => { + const modes = ["lite", "spec", "full"] as const; + for (const mode of modes) { + const feature = { id: "test", planningMode: mode }; + const result = getPlanningPromptPrefix(service, feature); + expect(result.length).toBeGreaterThan(100); + } + }); + + it("lite prompt should include correct structure", () => { + const feature = { id: "test", planningMode: "lite" as const }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Goal"); + expect(result).toContain("Approach"); + expect(result).toContain("Files to Touch"); + expect(result).toContain("Tasks"); + expect(result).toContain("Risks"); + }); + + it("spec prompt should include task format instructions", () => { + const feature = { id: "test", planningMode: "spec" as const }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Problem"); + expect(result).toContain("Solution"); + expect(result).toContain("Acceptance Criteria"); + expect(result).toContain("GIVEN-WHEN-THEN"); + expect(result).toContain("Implementation Tasks"); + expect(result).toContain("Verification"); + }); + + it("full prompt should include phases", () => { + const feature = { id: "test", planningMode: "full" as const }; + const result = getPlanningPromptPrefix(service, feature); + expect(result).toContain("Problem Statement"); + expect(result).toContain("User Story"); + expect(result).toContain("Technical Context"); + expect(result).toContain("Non-Goals"); + expect(result).toContain("Phase 1"); + expect(result).toContain("Phase 2"); + expect(result).toContain("Phase 3"); + }); + }); + + describe("status management", () => { + it("should report correct status", () => { + const status = service.getStatus(); + expect(status.runningFeatures).toEqual([]); + expect(status.isRunning).toBe(false); + expect(status.runningCount).toBe(0); + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts new file mode 100644 index 00000000..becdd309 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts @@ -0,0 +1,345 @@ +import { describe, it, expect } from "vitest"; + +/** + * Test the task parsing logic by reimplementing the parsing functions + * These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine + */ + +interface ParsedTask { + id: string; + description: string; + filePath?: string; + phase?: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null { + // Match pattern: - [ ] T###: Description | File: path + const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/); + if (!taskMatch) { + // Try simpler pattern without file + const simpleMatch = line.match(/- \[ \] (T\d{3}):\s*(.+)$/); + if (simpleMatch) { + return { + id: simpleMatch[1], + description: simpleMatch[2].trim(), + phase: currentPhase, + status: 'pending', + }; + } + return null; + } + + return { + id: taskMatch[1], + description: taskMatch[2].trim(), + filePath: taskMatch[3]?.trim(), + phase: currentPhase, + status: 'pending', + }; +} + +function parseTasksFromSpec(specContent: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + // Extract content within ```tasks ... ``` block + const tasksBlockMatch = specContent.match(/```tasks\s*([\s\S]*?)```/); + if (!tasksBlockMatch) { + // Try fallback: look for task lines anywhere in content + const taskLines = specContent.match(/- \[ \] T\d{3}:.*$/gm); + if (!taskLines) { + return tasks; + } + // Parse fallback task lines + let currentPhase: string | undefined; + for (const line of taskLines) { + const parsed = parseTaskLine(line, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + return tasks; + } + + const tasksContent = tasksBlockMatch[1]; + const lines = tasksContent.split('\n'); + + let currentPhase: string | undefined; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Check for phase header (e.g., "## Phase 1: Foundation") + const phaseMatch = trimmedLine.match(/^##\s*(.+)$/); + if (phaseMatch) { + currentPhase = phaseMatch[1].trim(); + continue; + } + + // Check for task line + if (trimmedLine.startsWith('- [ ]')) { + const parsed = parseTaskLine(trimmedLine, currentPhase); + if (parsed) { + tasks.push(parsed); + } + } + } + + return tasks; +} + +describe("Task Parsing", () => { + describe("parseTaskLine", () => { + it("should parse task with file path", () => { + const line = "- [ ] T001: Create user model | File: src/models/user.ts"; + const result = parseTaskLine(line); + expect(result).toEqual({ + id: "T001", + description: "Create user model", + filePath: "src/models/user.ts", + phase: undefined, + status: "pending", + }); + }); + + it("should parse task without file path", () => { + const line = "- [ ] T002: Setup database connection"; + const result = parseTaskLine(line); + expect(result).toEqual({ + id: "T002", + description: "Setup database connection", + phase: undefined, + status: "pending", + }); + }); + + it("should include phase when provided", () => { + const line = "- [ ] T003: Write tests | File: tests/user.test.ts"; + const result = parseTaskLine(line, "Phase 1: Foundation"); + expect(result?.phase).toBe("Phase 1: Foundation"); + }); + + it("should return null for invalid line", () => { + expect(parseTaskLine("- [ ] Invalid format")).toBeNull(); + expect(parseTaskLine("Not a task line")).toBeNull(); + expect(parseTaskLine("")).toBeNull(); + }); + + it("should handle multi-word descriptions", () => { + const line = "- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts"; + const result = parseTaskLine(line); + expect(result?.description).toBe("Implement user authentication with JWT tokens"); + }); + + it("should trim whitespace from description and file path", () => { + const line = "- [ ] T005: Create API endpoint | File: src/routes/api.ts "; + const result = parseTaskLine(line); + expect(result?.description).toBe("Create API endpoint"); + expect(result?.filePath).toBe("src/routes/api.ts"); + }); + }); + + describe("parseTasksFromSpec", () => { + it("should parse tasks from a tasks code block", () => { + const specContent = ` +## Specification + +Some description here. + +\`\`\`tasks +- [ ] T001: Create user model | File: src/models/user.ts +- [ ] T002: Add API endpoint | File: src/routes/users.ts +- [ ] T003: Write unit tests | File: tests/user.test.ts +\`\`\` + +## Notes +Some notes here. +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0].id).toBe("T001"); + expect(tasks[1].id).toBe("T002"); + expect(tasks[2].id).toBe("T003"); + }); + + it("should parse tasks with phases", () => { + const specContent = ` +\`\`\`tasks +## Phase 1: Foundation +- [ ] T001: Initialize project | File: package.json +- [ ] T002: Configure TypeScript | File: tsconfig.json + +## Phase 2: Implementation +- [ ] T003: Create main module | File: src/index.ts +- [ ] T004: Add utility functions | File: src/utils.ts + +## Phase 3: Testing +- [ ] T005: Write tests | File: tests/index.test.ts +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(5); + expect(tasks[0].phase).toBe("Phase 1: Foundation"); + expect(tasks[1].phase).toBe("Phase 1: Foundation"); + expect(tasks[2].phase).toBe("Phase 2: Implementation"); + expect(tasks[3].phase).toBe("Phase 2: Implementation"); + expect(tasks[4].phase).toBe("Phase 3: Testing"); + }); + + it("should return empty array for content without tasks", () => { + const specContent = "Just some text without any tasks"; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it("should fallback to finding task lines outside code block", () => { + const specContent = ` +## Implementation Plan + +- [ ] T001: First task | File: src/first.ts +- [ ] T002: Second task | File: src/second.ts +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + expect(tasks[0].id).toBe("T001"); + expect(tasks[1].id).toBe("T002"); + }); + + it("should handle empty tasks block", () => { + const specContent = ` +\`\`\`tasks +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toEqual([]); + }); + + it("should handle mixed valid and invalid lines", () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: Valid task | File: src/valid.ts +- Invalid line +Some other text +- [ ] T002: Another valid task +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(2); + }); + + it("should preserve task order", () => { + const specContent = ` +\`\`\`tasks +- [ ] T003: Third +- [ ] T001: First +- [ ] T002: Second +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks[0].id).toBe("T003"); + expect(tasks[1].id).toBe("T001"); + expect(tasks[2].id).toBe("T002"); + }); + + it("should handle task IDs with different numbers", () => { + const specContent = ` +\`\`\`tasks +- [ ] T001: First +- [ ] T010: Tenth +- [ ] T100: Hundredth +\`\`\` +`; + const tasks = parseTasksFromSpec(specContent); + expect(tasks).toHaveLength(3); + expect(tasks[0].id).toBe("T001"); + expect(tasks[1].id).toBe("T010"); + expect(tasks[2].id).toBe("T100"); + }); + }); + + describe("spec content generation patterns", () => { + it("should match the expected lite mode output format", () => { + const liteModeOutput = ` +1. **Goal**: Implement user registration +2. **Approach**: Create form component, add validation, connect to API +3. **Files to Touch**: src/components/Register.tsx, src/api/auth.ts +4. **Tasks**: + 1. Create registration form + 2. Add form validation + 3. Connect to backend API +5. **Risks**: Form state management complexity + +[PLAN_GENERATED] Planning outline complete. +`; + expect(liteModeOutput).toContain("[PLAN_GENERATED]"); + expect(liteModeOutput).toContain("Goal"); + expect(liteModeOutput).toContain("Approach"); + }); + + it("should match the expected spec mode output format", () => { + const specModeOutput = ` +1. **Problem**: Users cannot register for accounts + +2. **Solution**: Implement registration form with email/password validation + +3. **Acceptance Criteria**: + - GIVEN a new user, WHEN they fill in valid details, THEN account is created + +4. **Files to Modify**: + | File | Purpose | Action | + |------|---------|--------| + | src/Register.tsx | Registration form | create | + +5. **Implementation Tasks**: +\`\`\`tasks +- [ ] T001: Create registration component | File: src/Register.tsx +- [ ] T002: Add form validation | File: src/Register.tsx +\`\`\` + +6. **Verification**: Manual testing of registration flow + +[SPEC_GENERATED] Please review the specification above. +`; + expect(specModeOutput).toContain("[SPEC_GENERATED]"); + expect(specModeOutput).toContain("```tasks"); + expect(specModeOutput).toContain("T001"); + }); + + it("should match the expected full mode output format", () => { + const fullModeOutput = ` +1. **Problem Statement**: Users need ability to create accounts + +2. **User Story**: As a new user, I want to register, so that I can access the app + +3. **Acceptance Criteria**: + - **Happy Path**: GIVEN valid email, WHEN registering, THEN account created + - **Edge Cases**: GIVEN existing email, WHEN registering, THEN error shown + +4. **Technical Context**: + | Aspect | Value | + |--------|-------| + | Affected Files | src/Register.tsx | + +5. **Non-Goals**: Social login, password recovery + +6. **Implementation Tasks**: +\`\`\`tasks +## Phase 1: Foundation +- [ ] T001: Setup component structure | File: src/Register.tsx + +## Phase 2: Core Implementation +- [ ] T002: Add form logic | File: src/Register.tsx + +## Phase 3: Integration & Testing +- [ ] T003: Connect to API | File: src/api/auth.ts +\`\`\` + +[SPEC_GENERATED] Please review the comprehensive specification above. +`; + expect(fullModeOutput).toContain("Phase 1"); + expect(fullModeOutput).toContain("Phase 2"); + expect(fullModeOutput).toContain("Phase 3"); + expect(fullModeOutput).toContain("[SPEC_GENERATED]"); + }); + }); +}); diff --git a/apps/ui/package.json b/apps/ui/package.json index cfd0cb62..c7fc5523 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -49,8 +49,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", + "@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", diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 7cd79e71..cb274d0d 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -3,14 +3,15 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.TEST_PORT || 5173; const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === "true"; -const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true"; +const mockAgent = + process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true"; export default defineConfig({ testDir: "./tests", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: undefined, reporter: "html", timeout: 30000, use: { diff --git a/apps/ui/src/components/ui/autocomplete.tsx b/apps/ui/src/components/ui/autocomplete.tsx index 4b785b82..e70696b1 100644 --- a/apps/ui/src/components/ui/autocomplete.tsx +++ b/apps/ui/src/components/ui/autocomplete.tsx @@ -34,6 +34,7 @@ interface AutocompleteProps { emptyMessage?: string; className?: string; disabled?: boolean; + error?: boolean; icon?: LucideIcon; allowCreate?: boolean; createLabel?: (value: string) => string; @@ -57,6 +58,7 @@ export function Autocomplete({ emptyMessage = "No results found.", className, disabled = false, + error = false, icon: Icon, allowCreate = false, createLabel = (v) => `Create "${v}"`, @@ -129,6 +131,7 @@ export function Autocomplete({ className={cn( "w-full justify-between", Icon && "font-mono text-sm", + error && "border-destructive focus-visible:ring-destructive", className )} data-testid={testId} diff --git a/apps/ui/src/components/ui/branch-autocomplete.tsx b/apps/ui/src/components/ui/branch-autocomplete.tsx index f0fb3bb9..32b00ce1 100644 --- a/apps/ui/src/components/ui/branch-autocomplete.tsx +++ b/apps/ui/src/components/ui/branch-autocomplete.tsx @@ -10,6 +10,7 @@ interface BranchAutocompleteProps { placeholder?: string; className?: string; disabled?: boolean; + error?: boolean; "data-testid"?: string; } @@ -20,6 +21,7 @@ export function BranchAutocomplete({ placeholder = "Select a branch...", className, disabled = false, + error = false, "data-testid": testId, }: BranchAutocompleteProps) { // Always include "main" at the top of suggestions @@ -42,6 +44,7 @@ export function BranchAutocomplete({ emptyMessage="No branches found." className={className} disabled={disabled} + error={error} icon={GitBranch} allowCreate createLabel={(v) => `Create "${v}"`} diff --git a/apps/ui/src/components/ui/checkbox.tsx b/apps/ui/src/components/ui/checkbox.tsx index 37ac3c4e..fed17529 100644 --- a/apps/ui/src/components/ui/checkbox.tsx +++ b/apps/ui/src/components/ui/checkbox.tsx @@ -12,9 +12,23 @@ interface CheckboxProps extends Omit & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const CheckboxIndicator = CheckboxPrimitive.Indicator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + const Checkbox = React.forwardRef( - ({ className, onCheckedChange, ...props }, ref) => ( - ( + ( }} {...props} > - - - + + ) ); Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/apps/ui/src/components/ui/dialog.tsx b/apps/ui/src/components/ui/dialog.tsx index 63c8ee88..49c55a36 100644 --- a/apps/ui/src/components/ui/dialog.tsx +++ b/apps/ui/src/components/ui/dialog.tsx @@ -5,6 +5,36 @@ import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + title?: string; + } & React.RefAttributes +>; + function Dialog({ ...props }: React.ComponentProps) { @@ -29,12 +59,20 @@ function DialogClose({ return ; } +const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + function DialogOverlay({ className, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + className?: string; +}) { return ( - & { +export type DialogContentProps = Omit< + React.ComponentProps, + "ref" +> & { showCloseButton?: boolean; compact?: boolean; -}) { +}; + +const DialogContent = React.forwardRef< + HTMLDivElement, + DialogContentProps +>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => { // Check if className contains a custom max-width const hasCustomMaxWidth = typeof className === "string" && className.includes("max-w-"); @@ -65,7 +105,8 @@ function DialogContent({ return ( - {children} {showCloseButton && ( - Close - + )} - + ); -} +}); + +DialogContent.displayName = "DialogContent"; function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -136,27 +179,42 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogTitle({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ); } function DialogDescription({ className, + children, + title, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; + title?: string; +}) { return ( - + > + {children} + ); } diff --git a/apps/ui/src/components/ui/dropdown-menu.tsx b/apps/ui/src/components/ui/dropdown-menu.tsx index 26ae67f2..2ded42a2 100644 --- a/apps/ui/src/components/ui/dropdown-menu.tsx +++ b/apps/ui/src/components/ui/dropdown-menu.tsx @@ -5,9 +5,83 @@ import { Check, ChevronRight, Circle } from "lucide-react" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + } & React.RefAttributes +>; + +const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.HTMLAttributes & React.RefAttributes +>; + +const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.HTMLAttributes & React.RefAttributes +>; + +const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + } & React.RefAttributes +>; + +const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + const DropdownMenu = DropdownMenuPrimitive.Root -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +function DropdownMenuTrigger({ + children, + asChild, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) +} const DropdownMenuGroup = DropdownMenuPrimitive.Group @@ -15,15 +89,26 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup +function DropdownMenuRadioGroup({ + children, + ...props +}: React.ComponentProps & { children?: React.ReactNode }) { + return ( + + {children} + + ) +} const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean + children?: React.ReactNode + className?: string } >(({ className, inset, children, ...props }, ref) => ( - {children} - + )) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName const DropdownMenuSubContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( , - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, sideOffset = 4, ...props }, ref) => ( , React.ComponentPropsWithoutRef & { inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - +>(({ className, inset, children, ...props }, ref) => ( + + > + {children} + )) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + children?: React.ReactNode; + } >(({ className, children, checked, ...props }, ref) => ( - - + - + {children} - + )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + children?: React.ReactNode + } & React.HTMLAttributes >(({ className, children, ...props }, ref) => ( - - + - + {children} - + )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName @@ -141,9 +238,11 @@ const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean + children?: React.ReactNode + className?: string } ->(({ className, inset, ...props }, ref) => ( - (({ className, inset, children, ...props }, ref) => ( + + > + {children} + )) DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName const DropdownMenuSeparator = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( - & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + function Popover({ ...props }: React.ComponentProps) { @@ -11,9 +25,18 @@ function Popover({ } function PopoverTrigger({ + children, + asChild, ...props -}: React.ComponentProps) { - return +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) } function PopoverContent({ @@ -21,10 +44,12 @@ function PopoverContent({ align = "center", sideOffset = 4, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + className?: string; +}) { return ( - , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; + + diff --git a/apps/ui/src/components/ui/select.tsx b/apps/ui/src/components/ui/select.tsx new file mode 100644 index 00000000..482a1e11 --- /dev/null +++ b/apps/ui/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/apps/ui/src/components/ui/slider.tsx b/apps/ui/src/components/ui/slider.tsx index f607cbff..99cce313 100644 --- a/apps/ui/src/components/ui/slider.tsx +++ b/apps/ui/src/components/ui/slider.tsx @@ -3,6 +3,33 @@ import * as React from "react"; import * as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@/lib/utils"; +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const SliderTrackPrimitive = SliderPrimitive.Track as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const SliderRangePrimitive = SliderPrimitive.Range as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + +const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + interface SliderProps extends Omit, "defaultValue" | "dir"> { value?: number[]; defaultValue?: number[]; @@ -20,7 +47,7 @@ interface SliderProps extends Omit, "defau const Slider = React.forwardRef( ({ className, ...props }, ref) => ( - ( )} {...props} > - - - - - + + + + + ) ); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/apps/ui/src/components/ui/switch.tsx b/apps/ui/src/components/ui/switch.tsx new file mode 100644 index 00000000..47e4151a --- /dev/null +++ b/apps/ui/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; + + diff --git a/apps/ui/src/components/ui/tabs.tsx b/apps/ui/src/components/ui/tabs.tsx index 6a6b7b60..aaf6ea5e 100644 --- a/apps/ui/src/components/ui/tabs.tsx +++ b/apps/ui/src/components/ui/tabs.tsx @@ -4,41 +4,86 @@ import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsListPrimitive = TabsPrimitive.List as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsTriggerPrimitive = TabsPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + +const TabsContentPrimitive = TabsPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + className?: string; + } & React.RefAttributes +>; + function Tabs({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsList({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsTrigger({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } function TabsContent({ className, + children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + children?: React.ReactNode; + className?: string; +}) { return ( - + > + {children} + ) } diff --git a/apps/ui/src/components/ui/task-progress-panel.tsx b/apps/ui/src/components/ui/task-progress-panel.tsx new file mode 100644 index 00000000..1753f309 --- /dev/null +++ b/apps/ui/src/components/ui/task-progress-panel.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react"; +import { getElectronAPI } from "@/lib/electron"; +import type { AutoModeEvent } from "@/types/electron"; +import { Badge } from "@/components/ui/badge"; + +interface TaskInfo { + id: string; + description: string; + status: "pending" | "in_progress" | "completed"; + filePath?: string; + phase?: string; +} + +interface TaskProgressPanelProps { + featureId: string; + projectPath?: string; + className?: string; +} + +export function TaskProgressPanel({ featureId, projectPath, className }: TaskProgressPanelProps) { + const [tasks, setTasks] = useState([]); + const [isExpanded, setIsExpanded] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [currentTaskId, setCurrentTaskId] = useState(null); + + // Load initial tasks from feature's planSpec + const loadInitialTasks = useCallback(async () => { + if (!projectPath) { + setIsLoading(false); + return; + } + + try { + const api = getElectronAPI(); + if (!api?.features) { + setIsLoading(false); + return; + } + + const result = await api.features.get(projectPath, featureId); + if (result.success && result.feature?.planSpec?.tasks) { + const planTasks = result.feature.planSpec.tasks; + const currentId = result.feature.planSpec.currentTaskId; + const completedCount = result.feature.planSpec.tasksCompleted || 0; + + // Convert planSpec tasks to TaskInfo with proper status + const initialTasks: TaskInfo[] = planTasks.map((t: any, index: number) => ({ + id: t.id, + description: t.description, + filePath: t.filePath, + phase: t.phase, + status: index < completedCount + ? "completed" as const + : t.id === currentId + ? "in_progress" as const + : "pending" as const, + })); + + setTasks(initialTasks); + setCurrentTaskId(currentId || null); + } + } catch (error) { + console.error("Failed to load initial tasks:", error); + } finally { + setIsLoading(false); + } + }, [featureId, projectPath]); + + // Load initial state on mount + useEffect(() => { + loadInitialTasks(); + }, [loadInitialTasks]); + + // Listen to task events for real-time updates + useEffect(() => { + const api = getElectronAPI(); + if (!api?.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + // Only handle events for this feature + if (!("featureId" in event) || event.featureId !== featureId) return; + + switch (event.type) { + case "auto_mode_task_started": + if ("taskId" in event && "taskDescription" in event) { + const taskEvent = event as Extract; + setCurrentTaskId(taskEvent.taskId); + + setTasks((prev) => { + // Check if task already exists + const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId); + + if (existingIndex !== -1) { + // Update status to in_progress and mark previous as completed + return prev.map((t, idx) => { + if (t.id === taskEvent.taskId) { + return { ...t, status: "in_progress" as const }; + } + // If we are moving to a task that is further down the list, assume previous ones are completed + // This is a heuristic, but usually correct for sequential execution + if (idx < existingIndex && t.status !== "completed") { + return { ...t, status: "completed" as const }; + } + return t; + }); + } + + // Add new task if it doesn't exist (fallback) + return [ + ...prev, + { + id: taskEvent.taskId, + description: taskEvent.taskDescription, + status: "in_progress" as const, + }, + ]; + }); + } + break; + + case "auto_mode_task_complete": + if ("taskId" in event) { + const taskEvent = event as Extract; + setTasks((prev) => + prev.map((t) => + t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t + ) + ); + setCurrentTaskId(null); + } + break; + } + }); + + return unsubscribe; + }, [featureId]); + + const completedCount = tasks.filter((t) => t.status === "completed").length; + const totalCount = tasks.length; + const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + + if (isLoading || tasks.length === 0) { + return null; + } + + return ( +
+ + +
+
+
+ {/* Vertical Connector Line */} +
+ +
+ {tasks.map((task, index) => { + const isActive = task.status === "in_progress"; + const isCompleted = task.status === "completed"; + const isPending = task.status === "pending"; + + return ( +
+ {/* Icon Status */} +
+ {isCompleted && } + {isActive && } + {isPending && } +
+ + {/* Task Content */} +
+
+
+

+ {task.description} +

+ {isActive && ( + + Active + + )} +
+ + {(task.filePath || isActive) && ( +
+ {task.filePath ? ( + <> + + + {task.filePath} + + + ) : ( + /* Spacer */ + )} +
+ )} +
+
+
+ ); + })} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/ui/src/components/ui/tooltip.tsx b/apps/ui/src/components/ui/tooltip.tsx index a751bf68..bf535ac0 100644 --- a/apps/ui/src/components/ui/tooltip.tsx +++ b/apps/ui/src/components/ui/tooltip.tsx @@ -4,18 +4,47 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils" +// Type-safe wrappers for Radix UI primitives (React 19 compatibility) +const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + children?: React.ReactNode; + asChild?: boolean; + } & React.RefAttributes +>; + +const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExoticComponent< + React.ComponentPropsWithoutRef & { + className?: string; + } & React.RefAttributes +>; + const TooltipProvider = TooltipPrimitive.Provider const Tooltip = TooltipPrimitive.Root -const TooltipTrigger = TooltipPrimitive.Trigger +function TooltipTrigger({ + children, + asChild, + ...props +}: React.ComponentProps & { + children?: React.ReactNode; + asChild?: boolean; +}) { + return ( + + {children} + + ) +} const TooltipContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, sideOffset = 6, ...props }, ref) => ( - >( new Set() ); - const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = + const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false); const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false); const [showCompletedModal, setShowCompletedModal] = useState(false); const [deleteCompletedFeature, setDeleteCompletedFeature] = useState(null); + // State for viewing plan in read-only mode + const [viewPlanFeature, setViewPlanFeature] = useState(null); // Worktree dialog states const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = @@ -144,6 +156,8 @@ export function BoardView() { } = useSuggestionsState(); // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(""); + // Plan approval loading state + const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; const creatingSpecProjectPath = specCreatingForProject ?? undefined; @@ -276,6 +290,27 @@ export function BoardView() { const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({ currentProject }); + // Memoize the removed worktrees handler to prevent infinite loops + const handleRemovedWorktrees = useCallback( + (removedWorktrees: Array<{ path: string; branch: string }>) => { + // Reset features that were assigned to the removed worktrees (by branch) + hookFeatures.forEach((feature) => { + const matchesRemovedWorktree = removedWorktrees.some((removed) => { + // Match by branch name since worktreePath is no longer stored + return feature.branchName === removed.branch; + }); + + if (matchesRemovedWorktree) { + // Reset the feature's branch assignment + persistFeatureUpdate(feature.id, { + branchName: null as unknown as string | undefined, + }); + } + }); + }, + [hookFeatures, persistFeatureUpdate] + ); + // Get in-progress features for keyboard shortcuts (needed before actions hook) const inProgressFeaturesForShortcuts = useMemo(() => { return hookFeatures.filter((f) => { @@ -284,13 +319,12 @@ export function BoardView() { }); }, [hookFeatures, runningAutoTasks]); - // Get current worktree info (path and branch) for filtering features + // Get current worktree info (path) for filtering features // This needs to be before useBoardActions so we can pass currentWorktreeBranch const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreePath = currentWorktreeInfo?.path ?? null; - const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null; const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktrees = useMemo( () => @@ -300,8 +334,25 @@ export function BoardView() { [currentProject, worktreesByProject] ); + // Get the branch for the currently selected worktree + // Find the worktree that matches the current selection, or use main worktree + const selectedWorktree = useMemo(() => { + if (currentWorktreePath === null) { + // Primary worktree selected - find the main worktree + return worktrees.find((w) => w.isMain); + } else { + // Specific worktree selected - find it by path + return worktrees.find( + (w) => !w.isMain && pathsEqual(w.path, currentWorktreePath) + ); + } + }, [worktrees, currentWorktreePath]); + + // Get the current branch from the selected worktree (not from store which may be stale) + const currentWorktreeBranch = selectedWorktree?.branch ?? null; + // Get the branch for the currently selected worktree (for defaulting new features) - // Use the branch from currentWorktreeInfo, or fall back to main worktree's branch + // Use the branch from selectedWorktree, or fall back to main worktree's branch const selectedWorktreeBranch = currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main"; @@ -325,7 +376,7 @@ export function BoardView() { handleOutputModalNumberKeyPress, handleForceStopFeature, handleStartNextFeatures, - handleDeleteAllVerified, + handleArchiveAllVerified, } = useBoardActions({ currentProject, features: hookFeatures, @@ -353,6 +404,205 @@ export function BoardView() { currentWorktreeBranch, }); + // Client-side auto mode: periodically check for backlog items and move them to in-progress + // Use a ref to track the latest auto mode state so async operations always check the current value + const autoModeRunningRef = useRef(autoMode.isRunning); + useEffect(() => { + autoModeRunningRef.current = autoMode.isRunning; + }, [autoMode.isRunning]); + + // Use a ref to track the latest features to avoid effect re-runs when features change + const hookFeaturesRef = useRef(hookFeatures); + useEffect(() => { + hookFeaturesRef.current = hookFeatures; + }, [hookFeatures]); + + // Track features that are pending (started but not yet confirmed running) + const pendingFeaturesRef = useRef>(new Set()); + + // Listen to auto mode events to remove features from pending when they start running + useEffect(() => { + const api = getElectronAPI(); + if (!api?.autoMode) return; + + const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => { + if (!currentProject) return; + + // Only process events for the current project + const eventProjectPath = + "projectPath" in event ? event.projectPath : undefined; + if (eventProjectPath && eventProjectPath !== currentProject.path) { + return; + } + + switch (event.type) { + case "auto_mode_feature_start": + // Feature is now confirmed running - remove from pending + if (event.featureId) { + pendingFeaturesRef.current.delete(event.featureId); + } + break; + + case "auto_mode_feature_complete": + case "auto_mode_error": + // Feature completed or errored - remove from pending if still there + if (event.featureId) { + pendingFeaturesRef.current.delete(event.featureId); + } + break; + } + }); + + return unsubscribe; + }, [currentProject]); + + useEffect(() => { + if (!autoMode.isRunning || !currentProject) { + return; + } + + let isChecking = false; + let isActive = true; // Track if this effect is still active + + const checkAndStartFeatures = async () => { + // Check if auto mode is still running and effect is still active + // Use ref to get the latest value, not the closure value + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Prevent concurrent executions + if (isChecking) { + return; + } + + isChecking = true; + try { + // Double-check auto mode is still running before proceeding + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Count currently running tasks + pending features + const currentRunning = + runningAutoTasks.length + pendingFeaturesRef.current.size; + const availableSlots = maxConcurrency - currentRunning; + + // No available slots, skip check + if (availableSlots <= 0) { + return; + } + + // Filter backlog features by the currently selected worktree branch + // This logic mirrors use-board-column-features.ts for consistency + // Use ref to get the latest features without causing effect re-runs + const currentFeatures = hookFeaturesRef.current; + const backlogFeatures = currentFeatures.filter((f) => { + if (f.status !== "backlog") return false; + + const featureBranch = f.branchName; + + // Features without branchName are considered unassigned (show only on primary worktree) + if (!featureBranch) { + // No branch assigned - show only when viewing primary worktree + const isViewingPrimary = currentWorktreePath === null; + return isViewingPrimary; + } + + if (currentWorktreeBranch === null) { + // We're viewing main but branch hasn't been initialized yet + // Show features assigned to primary worktree's branch + return currentProject.path + ? isPrimaryWorktreeBranch(currentProject.path, featureBranch) + : false; + } + + // Match by branch name + return featureBranch === currentWorktreeBranch; + }); + + if (backlogFeatures.length === 0) { + return; + } + + // Sort by priority (lower number = higher priority, priority 1 is highest) + const sortedBacklog = [...backlogFeatures].sort( + (a, b) => (a.priority || 999) - (b.priority || 999) + ); + + // Filter out features with blocking dependencies if dependency blocking is enabled + const eligibleFeatures = enableDependencyBlocking + ? sortedBacklog.filter((f) => { + const blockingDeps = getBlockingDependencies(f, currentFeatures); + return blockingDeps.length === 0; + }) + : sortedBacklog; + + // Start features up to available slots + const featuresToStart = eligibleFeatures.slice(0, availableSlots); + + for (const feature of featuresToStart) { + // Check again before starting each feature + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Simplified: No worktree creation on client - server derives workDir from feature.branchName + // If feature has no branchName and primary worktree is selected, assign primary branch + if (currentWorktreePath === null && !feature.branchName) { + const primaryBranch = + (currentProject.path + ? getPrimaryWorktreeBranch(currentProject.path) + : null) || "main"; + await persistFeatureUpdate(feature.id, { + branchName: primaryBranch, + }); + } + + // Final check before starting implementation + if (!isActive || !autoModeRunningRef.current || !currentProject) { + return; + } + + // Start the implementation - server will derive workDir from feature.branchName + const started = await handleStartImplementation(feature); + + // If successfully started, track it as pending until we receive the start event + if (started) { + pendingFeaturesRef.current.add(feature.id); + } + } + } finally { + isChecking = false; + } + }; + + // Check immediately, then every 3 seconds + checkAndStartFeatures(); + const interval = setInterval(checkAndStartFeatures, 3000); + + return () => { + // Mark as inactive to prevent any pending async operations from continuing + isActive = false; + clearInterval(interval); + // Clear pending features when effect unmounts or dependencies change + pendingFeaturesRef.current.clear(); + }; + }, [ + autoMode.isRunning, + currentProject, + runningAutoTasks, + maxConcurrency, + // hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs + currentWorktreeBranch, + currentWorktreePath, + getPrimaryWorktreeBranch, + isPrimaryWorktreeBranch, + enableDependencyBlocking, + persistFeatureUpdate, + handleStartImplementation, + ]); + // Use keyboard shortcuts hook (after actions hook) useBoardKeyboardShortcuts({ features: hookFeatures, @@ -369,8 +619,6 @@ export function BoardView() { runningAutoTasks, persistFeatureUpdate, handleStartImplementation, - projectPath: currentProject?.path || null, - onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), }); // Use column features hook @@ -388,6 +636,130 @@ export function BoardView() { currentProject, }); + // Find feature for pending plan approval + const pendingApprovalFeature = useMemo(() => { + if (!pendingPlanApproval) return null; + return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null; + }, [pendingPlanApproval, hookFeatures]); + + // Handle plan approval + const handlePlanApprove = useCallback( + async (editedPlan?: string) => { + if (!pendingPlanApproval || !currentProject) return; + + const featureId = pendingPlanApproval.featureId; + setIsPlanApprovalLoading(true); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.approvePlan) { + throw new Error("Plan approval API not available"); + } + + const result = await api.autoMode.approvePlan( + pendingPlanApproval.projectPath, + pendingPlanApproval.featureId, + true, + editedPlan + ); + + if (result.success) { + // Immediately update local feature state to hide "Approve Plan" button + // Get current feature to preserve version + const currentFeature = hookFeatures.find(f => f.id === featureId); + updateFeature(featureId, { + planSpec: { + status: 'approved', + content: editedPlan || pendingPlanApproval.planContent, + version: currentFeature?.planSpec?.version || 1, + approvedAt: new Date().toISOString(), + reviewedByUser: true, + }, + }); + // Reload features from server to ensure sync + loadFeatures(); + } else { + console.error("[Board] Failed to approve plan:", result.error); + } + } catch (error) { + console.error("[Board] Error approving plan:", error); + } finally { + setIsPlanApprovalLoading(false); + setPendingPlanApproval(null); + } + }, + [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + ); + + // Handle plan rejection + const handlePlanReject = useCallback( + async (feedback?: string) => { + if (!pendingPlanApproval || !currentProject) return; + + const featureId = pendingPlanApproval.featureId; + setIsPlanApprovalLoading(true); + try { + const api = getElectronAPI(); + if (!api?.autoMode?.approvePlan) { + throw new Error("Plan approval API not available"); + } + + const result = await api.autoMode.approvePlan( + pendingPlanApproval.projectPath, + pendingPlanApproval.featureId, + false, + undefined, + feedback + ); + + if (result.success) { + // Immediately update local feature state + // Get current feature to preserve version + const currentFeature = hookFeatures.find(f => f.id === featureId); + updateFeature(featureId, { + status: 'backlog', + planSpec: { + status: 'rejected', + content: pendingPlanApproval.planContent, + version: currentFeature?.planSpec?.version || 1, + reviewedByUser: true, + }, + }); + // Reload features from server to ensure sync + loadFeatures(); + } else { + console.error("[Board] Failed to reject plan:", result.error); + } + } catch (error) { + console.error("[Board] Error rejecting plan:", error); + } finally { + setIsPlanApprovalLoading(false); + setPendingPlanApproval(null); + } + }, + [pendingPlanApproval, currentProject, setPendingPlanApproval, updateFeature, loadFeatures, hookFeatures] + ); + + // Handle opening approval dialog from feature card button + const handleOpenApprovalDialog = useCallback( + (feature: Feature) => { + if (!feature.planSpec?.content || !currentProject) return; + + // Determine the planning mode for approval (skip should never have a plan requiring approval) + const mode = feature.planningMode; + const approvalMode: "lite" | "spec" | "full" = + mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec'; + + // Re-open the approval dialog with the feature's plan data + setPendingPlanApproval({ + featureId: feature.id, + projectPath: currentProject.path, + planContent: feature.planSpec.content, + planningMode: approvalMode, + }); + }, + [currentProject, setPendingPlanApproval] + ); + if (!currentProject) { return (
autoMode.start()} - onStopAutoMode={() => autoMode.stop()} + onAutoModeToggle={(enabled) => { + if (enabled) { + autoMode.start(); + } else { + autoMode.stop(); + } + }} onAddFeature={() => setShowAddDialog(true)} addFeatureShortcut={{ key: shortcuts.addFeature, @@ -453,10 +830,10 @@ export function BoardView() { setSelectedWorktreeForAction(worktree); setShowCreateBranchDialog(true); }} + onRemovedWorktrees={handleRemovedWorktrees} runningFeatureIds={runningAutoTasks} features={hookFeatures.map((f) => ({ id: f.id, - worktreePath: f.worktreePath, branchName: f.branchName, }))} /> @@ -505,13 +882,15 @@ export function BoardView() { onCommit={handleCommitFeature} onComplete={handleCompleteFeature} onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} shortcuts={shortcuts} onStartNextFeatures={handleStartNextFeatures} onShowSuggestions={() => setShowSuggestionsDialog(true)} suggestionsCount={suggestionsCount} - onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} />
@@ -551,6 +930,7 @@ export function BoardView() { branchSuggestions={branchSuggestions} defaultSkipTests={defaultSkipTests} defaultBranch={selectedWorktreeBranch} + currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -563,6 +943,7 @@ export function BoardView() { onUpdate={handleUpdateFeature} categorySuggestions={categorySuggestions} branchSuggestions={branchSuggestions} + currentBranch={currentWorktreeBranch || undefined} isMaximized={isMaximized} showProfilesOnly={showProfilesOnly} aiProfiles={aiProfiles} @@ -579,14 +960,14 @@ export function BoardView() { onNumberKeyPress={handleOutputModalNumberKeyPress} /> - {/* Delete All Verified Dialog */} - { - await handleDeleteAllVerified(); - setShowDeleteAllVerifiedDialog(false); + await handleArchiveAllVerified(); + setShowArchiveAllVerifiedDialog(false); }} /> @@ -616,6 +997,34 @@ export function BoardView() { setIsGenerating={setIsGeneratingSuggestions} /> + {/* Plan Approval Dialog */} + { + if (!open) { + setPendingPlanApproval(null); + } + }} + feature={pendingApprovalFeature} + planContent={pendingPlanApproval?.planContent || ""} + onApprove={handlePlanApprove} + onReject={handlePlanReject} + isLoading={isPlanApprovalLoading} + /> + + {/* View Plan Dialog (read-only) */} + {viewPlanFeature && viewPlanFeature.planSpec?.content && ( + !open && setViewPlanFeature(null)} + feature={viewPlanFeature} + planContent={viewPlanFeature.planSpec.content} + onApprove={() => setViewPlanFeature(null)} + onReject={() => setViewPlanFeature(null)} + viewOnly={true} + /> + )} + {/* Create Worktree Dialog */} { - // Reset features that were assigned to the deleted worktree + // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { - const matchesByPath = - feature.worktreePath && - pathsEqual(feature.worktreePath, deletedWorktree.path); - const matchesByBranch = - feature.branchName === deletedWorktree.branch; - - if (matchesByPath || matchesByBranch) { - // Reset the feature's worktree assignment + // Match by branch name since worktreePath is no longer stored + if (feature.branchName === deletedWorktree.branch) { + // Reset the feature's branch assignment persistFeatureUpdate(feature.id, { branchName: null as unknown as string | undefined, - worktreePath: null as unknown as string | undefined, }); } }); 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 56e8842b..c21a3233 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -2,7 +2,9 @@ import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Slider } from "@/components/ui/slider"; -import { Play, StopCircle, Plus, Users } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Plus, Users } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; interface BoardHeaderProps { @@ -10,8 +12,7 @@ interface BoardHeaderProps { maxConcurrency: number; onConcurrencyChange: (value: number) => void; isAutoModeRunning: boolean; - onStartAutoMode: () => void; - onStopAutoMode: () => void; + onAutoModeToggle: (enabled: boolean) => void; onAddFeature: () => void; addFeatureShortcut: KeyboardShortcut; isMounted: boolean; @@ -22,8 +23,7 @@ export function BoardHeader({ maxConcurrency, onConcurrencyChange, isAutoModeRunning, - onStartAutoMode, - onStopAutoMode, + onAutoModeToggle, onAddFeature, addFeatureShortcut, isMounted, @@ -62,29 +62,20 @@ export function BoardHeader({ {/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {isMounted && ( - <> - {isAutoModeRunning ? ( - - ) : ( - - )} - +
+ + +
)} void; onImplement?: () => void; onComplete?: () => void; + onViewPlan?: () => void; + onApprovePlan?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -129,6 +131,8 @@ export const KanbanCard = memo(function KanbanCard({ onCommit, onImplement, onComplete, + onViewPlan, + onApprovePlan, hasContext, isCurrentAutoTask, shortcutKey, @@ -256,7 +260,7 @@ export const KanbanCard = memo(function KanbanCard({ feature.status === "backlog" || feature.status === "waiting_approval" || feature.status === "verified" || - (feature.skipTests && !isCurrentAutoTask); + (feature.status === "in_progress" && !isCurrentAutoTask); const { attributes, listeners, @@ -875,9 +879,26 @@ export const KanbanCard = memo(function KanbanCard({ )} {/* Actions */} -
+
{isCurrentAutoTask && ( <> + {/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} {onViewOutput && ( + )} {feature.skipTests && onManualVerify ? ( + {feature.planSpec?.content && onViewPlan && ( + + )} {onImplement && (
{useWorktrees && ( -
- - - setNewFeature({ ...newFeature, branchName: value }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="feature-branch-input" - /> -

- Work will be done in this branch. A worktree will be created if - needed. -

-
+ + setNewFeature({ ...newFeature, branchName: value }) + } + branchSuggestions={branchSuggestions} + currentBranch={currentBranch} + testIdPrefix="feature" + /> )} {/* Priority Selector */} @@ -454,11 +480,22 @@ export function AddFeatureDialog({ )} - {/* Testing Tab */} - + {/* Options Tab */} + + {/* Planning Mode Section */} + + +
+ + {/* Testing Section */} @@ -478,6 +515,11 @@ export function AddFeatureDialog({ hotkey={{ key: "Enter", cmdCtrl: true }} hotkeyActive={open} data-testid="confirm-add-feature" + disabled={ + useWorktrees && + !useCurrentBranch && + !newFeature.branchName.trim() + } > Add Feature 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 59dc9b8e..f91e8f65 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 @@ -11,6 +11,7 @@ import { Loader2, List, FileText, GitBranch } from "lucide-react"; import { getElectronAPI } from "@/lib/electron"; import { LogViewer } from "@/components/ui/log-viewer"; import { GitDiffPanel } from "@/components/ui/git-diff-panel"; +import { TaskProgressPanel } from "@/components/ui/task-progress-panel"; import { useAppStore } from "@/store/app-store"; import type { AutoModeEvent } from "@/types/electron"; @@ -168,6 +169,64 @@ export function AgentOutputModal({ newContent = prepContent; break; + case "planning_started": + // Show when planning mode begins + if ("mode" in event && "message" in event) { + const modeLabel = + event.mode === "lite" + ? "Lite" + : event.mode === "spec" + ? "Spec" + : "Full"; + newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`; + } + break; + case "plan_approval_required": + // Show when plan requires approval + if ("planningMode" in event) { + newContent = `\n⏸️ Plan generated - waiting for your approval...\n`; + } + break; + case "plan_approved": + // Show when plan is manually approved + if ("hasEdits" in event) { + newContent = event.hasEdits + ? `\n✅ Plan approved (with edits) - continuing to implementation...\n` + : `\n✅ Plan approved - continuing to implementation...\n`; + } + break; + case "plan_auto_approved": + // Show when plan is auto-approved + newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`; + break; + case "plan_revision_requested": + // Show when user requests plan revision + if ("planVersion" in event) { + const revisionEvent = event as Extract; + newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`; + } + break; + case "auto_mode_task_started": + // Show when a task starts + if ("taskId" in event && "taskDescription" in event) { + const taskEvent = event as Extract; + newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`; + } + break; + case "auto_mode_task_complete": + // Show task completion progress + if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) { + const taskEvent = event as Extract; + newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`; + } + break; + case "auto_mode_phase_complete": + // Show phase completion for full mode + if ("phaseNumber" in event) { + const phaseEvent = event as Extract; + newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`; + } + break; case "auto_mode_feature_complete": const emoji = event.passes ? "✅" : "⚠️"; newContent = `\n${emoji} Task completed: ${event.message}\n`; @@ -287,6 +346,13 @@ export function AgentOutputModal({ + {/* Task Progress Panel - shows when tasks are being executed */} + + {viewMode === "changes" ? (
{projectPath ? ( diff --git a/apps/ui/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx new file mode 100644 index 00000000..cb6d2f0d --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/archive-all-verified-dialog.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Archive } from "lucide-react"; + +interface ArchiveAllVerifiedDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + verifiedCount: number; + onConfirm: () => void; +} + +export function ArchiveAllVerifiedDialog({ + open, + onOpenChange, + verifiedCount, + onConfirm, +}: ArchiveAllVerifiedDialogProps) { + return ( + + + + Archive All Verified Features + + Are you sure you want to archive all verified features? They will be + moved to the archive box. + {verifiedCount > 0 && ( + + {verifiedCount} feature(s) will be archived. + + )} + + + + + + + + + ); +} + + diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 9f1dd942..b15fa540 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Dialog, DialogContent, @@ -48,18 +48,26 @@ export function CreatePRDialog({ const [prUrl, setPrUrl] = useState(null); const [browserUrl, setBrowserUrl] = useState(null); const [showBrowserFallback, setShowBrowserFallback] = useState(false); + // Track whether an operation completed that warrants a refresh + const operationCompletedRef = useRef(false); // Reset state when dialog opens or worktree changes useEffect(() => { if (open) { - // Only reset form fields, not the result states (prUrl, browserUrl, showBrowserFallback) - // These are set by the API response and should persist until dialog closes + // Reset form fields setTitle(""); setBody(""); setCommitMessage(""); setBaseBranch("main"); setIsDraft(false); setError(null); + // Also reset result states when opening for a new worktree + // This prevents showing stale PR URLs from previous worktrees + setPrUrl(null); + setBrowserUrl(null); + setShowBrowserFallback(false); + // Reset operation tracking + operationCompletedRef.current = false; } else { // Reset everything when dialog closes setTitle(""); @@ -71,6 +79,7 @@ export function CreatePRDialog({ setPrUrl(null); setBrowserUrl(null); setShowBrowserFallback(false); + operationCompletedRef.current = false; } }, [open, worktree?.path]); @@ -97,6 +106,8 @@ export function CreatePRDialog({ if (result.success && result.result) { if (result.result.prCreated && result.result.prUrl) { setPrUrl(result.result.prUrl); + // Mark operation as completed for refresh on close + operationCompletedRef.current = true; toast.success("Pull request created!", { description: `PR created from ${result.result.branch}`, action: { @@ -104,7 +115,8 @@ export function CreatePRDialog({ onClick: () => window.open(result.result!.prUrl!, "_blank"), }, }); - onCreated(); + // Don't call onCreated() here - keep dialog open to show success message + // onCreated() will be called when user closes the dialog } else { // Branch was pushed successfully const prError = result.result.prError; @@ -116,6 +128,8 @@ export function CreatePRDialog({ if (prError === "gh_cli_not_available" || !result.result.ghCliAvailable) { setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); + // Mark operation as completed - branch was pushed successfully + operationCompletedRef.current = true; toast.success("Branch pushed", { description: result.result.committed ? `Commit ${result.result.commitHash} pushed to ${result.result.branch}` @@ -141,6 +155,8 @@ export function CreatePRDialog({ // Show error but also provide browser option setBrowserUrl(result.result.browserUrl ?? null); setShowBrowserFallback(true); + // Mark operation as completed - branch was pushed even though PR creation failed + operationCompletedRef.current = true; toast.error("PR creation failed", { description: errorMessage, duration: 8000, @@ -181,19 +197,13 @@ export function CreatePRDialog({ }; const handleClose = () => { + // Only call onCreated() if an actual operation completed + // This prevents unnecessary refreshes when user cancels + if (operationCompletedRef.current) { + onCreated(); + } onOpenChange(false); - // Reset state after dialog closes - setTimeout(() => { - setTitle(""); - setBody(""); - setCommitMessage(""); - setBaseBranch("main"); - setIsDraft(false); - setError(null); - setPrUrl(null); - setBrowserUrl(null); - setShowBrowserFallback(false); - }, 200); + // State reset is handled by useEffect when open becomes false }; if (!worktree) return null; @@ -227,13 +237,18 @@ export function CreatePRDialog({ Your PR is ready for review

- +
+ + +
) : shouldShowBrowserFallback ? (
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 60822340..5afe6b71 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-button"; import { Label } from "@/components/ui/label"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; -import { BranchAutocomplete } from "@/components/ui/branch-autocomplete"; import { DescriptionImageDropZone, FeatureImagePath as DescriptionImagePath, @@ -22,6 +21,7 @@ import { import { MessageSquare, Settings2, + SlidersHorizontal, FlaskConical, Sparkles, ChevronDown, @@ -36,6 +36,7 @@ import { ThinkingLevel, AIProfile, useAppStore, + PlanningMode, } from "@/store/app-store"; import { ModelSelector, @@ -43,6 +44,8 @@ import { ProfileQuickSelect, TestingTabContent, PrioritySelector, + BranchSelector, + PlanningModeSelector, } from "../shared"; import { DropdownMenu, @@ -65,12 +68,15 @@ interface EditFeatureDialogProps { model: AgentModel; thinkingLevel: ThinkingLevel; imagePaths: DescriptionImagePath[]; - branchName: string; + branchName: string; // Can be empty string to use current branch priority: number; + planningMode: PlanningMode; + requirePlanApproval: boolean; } ) => void; categorySuggestions: string[]; branchSuggestions: string[]; + currentBranch?: string; isMaximized: boolean; showProfilesOnly: boolean; aiProfiles: AIProfile[]; @@ -83,12 +89,17 @@ export function EditFeatureDialog({ onUpdate, categorySuggestions, branchSuggestions, + currentBranch, isMaximized, showProfilesOnly, aiProfiles, allFeatures, }: EditFeatureDialogProps) { const [editingFeature, setEditingFeature] = useState(feature); + const [useCurrentBranch, setUseCurrentBranch] = useState(() => { + // If feature has no branchName, default to using current branch + return !feature?.branchName; + }); const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState(() => new Map()); const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); @@ -97,13 +108,20 @@ export function EditFeatureDialog({ "improve" | "technical" | "simplify" | "acceptance" >("improve"); const [showDependencyTree, setShowDependencyTree] = useState(false); + const [planningMode, setPlanningMode] = useState(feature?.planningMode ?? 'skip'); + const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false); // Get enhancement model and worktrees setting from store const { enhancementModel, useWorktrees } = useAppStore(); useEffect(() => { setEditingFeature(feature); - if (!feature) { + if (feature) { + setPlanningMode(feature.planningMode ?? 'skip'); + setRequirePlanApproval(feature.requirePlanApproval ?? false); + // If feature has no branchName, default to using current branch + setUseCurrentBranch(!feature.branchName); + } else { setEditFeaturePreviewMap(new Map()); setShowEditAdvancedOptions(false); } @@ -112,6 +130,18 @@ export function EditFeatureDialog({ const handleUpdate = () => { if (!editingFeature) return; + // Validate branch selection when "other branch" is selected and branch selector is enabled + const isBranchSelectorEnabled = editingFeature.status === "backlog"; + if ( + useWorktrees && + isBranchSelectorEnabled && + !useCurrentBranch && + !editingFeature.branchName?.trim() + ) { + toast.error("Please select a branch name"); + return; + } + const selectedModel = (editingFeature.model ?? "opus") as AgentModel; const normalizedThinking: ThinkingLevel = modelSupportsThinking( selectedModel @@ -119,6 +149,13 @@ export function EditFeatureDialog({ ? editingFeature.thinkingLevel ?? "none" : "none"; + // Use current branch if toggle is on + // If currentBranch is provided (non-primary worktree), use it + // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) + const finalBranchName = useCurrentBranch + ? (currentBranch || "") + : editingFeature.branchName || ""; + const updates = { category: editingFeature.category, description: editingFeature.description, @@ -127,8 +164,10 @@ export function EditFeatureDialog({ model: selectedModel, thinkingLevel: normalizedThinking, imagePaths: editingFeature.imagePaths ?? [], - branchName: editingFeature.branchName ?? "main", + branchName: finalBranchName, priority: editingFeature.priority ?? 2, + planningMode, + requirePlanApproval, }; onUpdate(editingFeature.id, updates); @@ -206,13 +245,13 @@ export function EditFeatureDialog({ { + onPointerDownOutside={(e: CustomEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); } }} - onInteractOutside={(e) => { + onInteractOutside={(e: CustomEvent) => { const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); @@ -236,9 +275,9 @@ export function EditFeatureDialog({ Model - - - Testing + + + Options @@ -338,33 +377,21 @@ export function EditFeatureDialog({ />
{useWorktrees && ( -
- - - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branches={branchSuggestions} - placeholder="Select or create branch..." - data-testid="edit-feature-branch" - disabled={editingFeature.status !== "backlog"} - /> - {editingFeature.status !== "backlog" && ( -

- Branch cannot be changed after work has started. -

- )} - {editingFeature.status === "backlog" && ( -

- Work will be done in this branch. A worktree will be created - if needed. -

- )} -
+ + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branchSuggestions={branchSuggestions} + currentBranch={currentBranch} + disabled={editingFeature.status !== "backlog"} + testIdPrefix="edit-feature" + /> )} {/* Priority Selector */} @@ -449,11 +476,22 @@ export function EditFeatureDialog({ )}
- {/* Testing Tab */} - + {/* Options Tab */} + + {/* Planning Mode Section */} + + +
+ + {/* Testing Section */} @@ -485,6 +523,12 @@ export function EditFeatureDialog({ hotkey={{ key: "Enter", cmdCtrl: true }} hotkeyActive={!!editingFeature} data-testid="confirm-edit-feature" + disabled={ + useWorktrees && + editingFeature.status === "backlog" && + !useCurrentBranch && + !editingFeature.branchName?.trim() + } > Save Changes diff --git a/apps/ui/src/components/views/board-view/dialogs/follow-up-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/follow-up-dialog.tsx index ababb85c..99765345 100644 --- a/apps/ui/src/components/views/board-view/dialogs/follow-up-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/follow-up-dialog.tsx @@ -57,7 +57,7 @@ export function FollowUpDialog({ { + onKeyDown={(e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) { e.preventDefault(); onSend(); 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 5685ddcb..52538d4e 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -1,8 +1,9 @@ export { AddFeatureDialog } from "./add-feature-dialog"; export { AgentOutputModal } from "./agent-output-modal"; export { CompletedFeaturesModal } from "./completed-features-modal"; -export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog"; +export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog"; export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog"; export { EditFeatureDialog } from "./edit-feature-dialog"; export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; export { FollowUpDialog } from "./follow-up-dialog"; +export { PlanApprovalDialog } from "./plan-approval-dialog"; diff --git a/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx new file mode 100644 index 00000000..ff710105 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/plan-approval-dialog.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Markdown } from "@/components/ui/markdown"; +import { Label } from "@/components/ui/label"; +import { Feature } from "@/store/app-store"; +import { Check, RefreshCw, Edit2, Eye, Loader2 } from "lucide-react"; + +interface PlanApprovalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + feature: Feature | null; + planContent: string; + onApprove: (editedPlan?: string) => void; + onReject: (feedback?: string) => void; + isLoading?: boolean; + viewOnly?: boolean; +} + +export function PlanApprovalDialog({ + open, + onOpenChange, + feature, + planContent, + onApprove, + onReject, + isLoading = false, + viewOnly = false, +}: PlanApprovalDialogProps) { + const [isEditMode, setIsEditMode] = useState(false); + const [editedPlan, setEditedPlan] = useState(planContent); + const [showRejectFeedback, setShowRejectFeedback] = useState(false); + const [rejectFeedback, setRejectFeedback] = useState(""); + + // Reset state when dialog opens or plan content changes + useEffect(() => { + if (open) { + setEditedPlan(planContent); + setIsEditMode(false); + setShowRejectFeedback(false); + setRejectFeedback(""); + } + }, [open, planContent]); + + const handleApprove = () => { + // Only pass edited plan if it was modified + const wasEdited = editedPlan !== planContent; + onApprove(wasEdited ? editedPlan : undefined); + }; + + const handleReject = () => { + if (showRejectFeedback) { + onReject(rejectFeedback.trim() || undefined); + } else { + setShowRejectFeedback(true); + } + }; + + const handleCancelReject = () => { + setShowRejectFeedback(false); + setRejectFeedback(""); + }; + + const handleClose = (open: boolean) => { + if (!open && !isLoading) { + onOpenChange(false); + } + }; + + return ( + + + + {viewOnly ? "View Plan" : "Review Plan"} + + {viewOnly + ? "View the generated plan for this feature." + : "Review the generated plan before implementation begins."} + {feature && ( + + Feature: {feature.description.slice(0, 150)} + {feature.description.length > 150 ? "..." : ""} + + )} + + + +
+ {/* Mode Toggle - Only show when not in viewOnly mode */} + {!viewOnly && ( +
+ + +
+ )} + + {/* Plan Content */} +
+ {isEditMode && !viewOnly ? ( +