diff --git a/.gitignore b/.gitignore index 1c4bbe2ca7..5a0fc481d1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ Thumbs.db ehthumbs.db Desktop.ini +nul # =========================== # Security - Environment & Secrets diff --git a/apps/backend/implementation_plan/plan.py b/apps/backend/implementation_plan/plan.py index 01518f245b..de5220012a 100644 --- a/apps/backend/implementation_plan/plan.py +++ b/apps/backend/implementation_plan/plan.py @@ -203,9 +203,15 @@ def update_status_from_subtasks(self): self.planStatus = "in_progress" else: # All subtasks pending - # Preserve human_review/review status if it's for plan approval stage + # Preserve review status if it's for plan approval stage # (spec is complete, waiting for user to approve before coding starts) - if self.status == "human_review" and self.planStatus == "review": + # This covers both: + # - human_review + review: legacy plan review state + # - stopped + awaiting_review: new plan review state (set by spec_runner.py --no-build) + if self.status in ("human_review", "stopped") and self.planStatus in ( + "review", + "awaiting_review", + ): # Keep the plan approval status - don't reset to backlog pass else: diff --git a/apps/backend/runners/spec_runner.py b/apps/backend/runners/spec_runner.py index 210c712017..f9eb91d191 100644 --- a/apps/backend/runners/spec_runner.py +++ b/apps/backend/runners/spec_runner.py @@ -46,6 +46,7 @@ import asyncio import io +import json import os from pathlib import Path @@ -104,6 +105,7 @@ init_sentry(component="spec-runner") +from core.file_utils import write_json_atomic from debug import debug, debug_error, debug_section, debug_success from phase_config import resolve_model_id from review import ReviewState @@ -372,6 +374,53 @@ def main(): # Execute run.py - replace current process os.execv(sys.executable, run_cmd) + else: + # --no-build specified: Set planStatus to 'awaiting_review' to signal frontend + # that planning is complete and user review is required before coding starts + debug( + "spec_runner", + "--no-build flag detected, setting awaiting_review status", + ) + print() + print_status("--no-build flag: Setting plan to awaiting review", "info") + + plan_path = orchestrator.spec_dir / "implementation_plan.json" + debug("spec_runner", f"Looking for plan file at: {plan_path}") + + if plan_path.exists(): + try: + plan_data = json.loads(plan_path.read_text(encoding="utf-8")) + plan_data["planStatus"] = "awaiting_review" + plan_data["status"] = "stopped" # Mark as stopped for review + write_json_atomic( + plan_path, plan_data, indent=2, ensure_ascii=False + ) + debug( + "spec_runner", + "Set planStatus to 'awaiting_review' for frontend", + ) + print() + print_status( + "Planning complete. Awaiting review before coding.", "success" + ) + print( + f" {muted('Review the spec at:')} {orchestrator.spec_dir / 'spec.md'}" + ) + print( + f" {muted('When ready, restart the task in the UI to begin coding.')}" + ) + except (json.JSONDecodeError, OSError) as e: + debug_error("spec_runner", f"Failed to update plan status: {e}") + print_status(f"Failed to update plan status: {e}", "error") + sys.exit(1) + else: + debug_error("spec_runner", f"Plan file not found at: {plan_path}") + print_status( + f"Warning: implementation_plan.json not found at {plan_path}", + "warning", + ) + sys.exit(1) + sys.exit(0) except KeyboardInterrupt: diff --git a/apps/backend/task_logger/storage.py b/apps/backend/task_logger/storage.py index 6e50e89d71..a67101f646 100644 --- a/apps/backend/task_logger/storage.py +++ b/apps/backend/task_logger/storage.py @@ -129,6 +129,7 @@ def update_phase_status( self._data["phases"][phase]["status"] = status if completed_at: self._data["phases"][phase]["completed_at"] = completed_at + self.save() def set_phase_started(self, phase: str, started_at: str) -> None: """ diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 7ce8790954..2ff404b2aa 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -148,7 +148,14 @@ export class AgentManager extends EventEmitter { } // Check if user requires review before coding - if (!metadata?.requireReviewBeforeCoding) { + if (metadata?.requireReviewBeforeCoding) { + // Human review required: Stop after spec creation, don't start build + // Frontend will handle the review gate and resume coding after approval + // NOTE: Also pass --auto-approve so the spec gets approved during creation + // (otherwise spec_runner exits early with "spec not approved" error) + args.push('--no-build'); + args.push('--auto-approve'); + } else { // Auto-approve: When user starts a task from the UI without requiring review args.push('--auto-approve'); } @@ -175,8 +182,9 @@ export class AgentManager extends EventEmitter { // Store context for potential restart this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch); - // Note: This is spec-creation but it chains to task-execution via run.py - await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + // Use 'spec-creation' processType so exit handler can distinguish planning from coding + // This prevents infinite loop when auto-continue triggers after coding completes + await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'spec-creation'); } /** diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 3cb23d30d7..657e42e551 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -1,6 +1,6 @@ import type { BrowserWindow } from "electron"; import path from "path"; -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from "../../shared/constants"; import { wouldPhaseRegress, @@ -26,6 +26,7 @@ import { persistPlanStatusSync, getPlanPath } from "./task/plan-file-utils"; import { findTaskWorktree } from "../worktree-paths"; import { findTaskAndProject } from "./task/shared"; import { safeSendToRenderer } from "./utils"; +import { atomicWriteFileSync } from "../utils/file-utils"; /** * Validates status transitions to prevent invalid state changes. @@ -135,7 +136,7 @@ export function registerAgenteventsHandlers( agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => { // Get project info early for multi-project filtering (issue #723) - const { project: exitProject } = findTaskAndProject(taskId); + const { project: exitProject, task: exitTask } = findTaskAndProject(taskId); const exitProjectId = exitProject?.id; // Send final plan state to renderer BEFORE unwatching @@ -153,9 +154,188 @@ export function registerAgenteventsHandlers( fileWatcher.unwatch(taskId); - if (processType === "spec-creation") { - console.warn(`[Task ${taskId}] Spec creation completed with code ${code}`); - return; + // FIX: Planning-to-coding transition (Issue #1231) + // Only run this logic for spec-creation process (planning phase). + // This prevents infinite loop: without this check, when coding (task-execution) completes, + // this handler would incorrectly call startTaskExecution again, causing an infinite loop. + // The processType check ensures we only auto-continue from planning → coding, not coding → coding. + if (processType === 'spec-creation' && (code === 0 || code === 1) && exitProject && exitTask) { + const specsBaseDir = getSpecsDir(exitProject.autoBuildPath); + const specDir = path.join(exitProject.path, specsBaseDir, exitTask.specId); + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + const metadataPath = path.join(specDir, "task_metadata.json"); + + // Check if spec and plan exist (planning completed) + const hasSpec = existsSync(specFilePath); + const hasPlan = existsSync(planPath); + + if (hasSpec && hasPlan) { + // Read metadata and plan once to avoid multiple file reads + let metadata: Record = {}; + let planContent: Record = {}; + + if (existsSync(metadataPath)) { + try { + metadata = JSON.parse(readFileSync(metadataPath, "utf-8")); + } catch (err) { + console.error(`[Task ${taskId}] Failed to read metadata:`, err); + } + } + + try { + planContent = JSON.parse(readFileSync(planPath, "utf-8")); + } catch (err) { + console.error(`[Task ${taskId}] Failed to read plan:`, err); + } + + const requireReviewBeforeCoding = metadata.requireReviewBeforeCoding === true; + const allSubtasks = (Array.isArray(planContent.phases) ? planContent.phases : []) + .flatMap((p: { subtasks?: unknown[] }) => p.subtasks || []); + const planHasSubtasks = allSubtasks.length > 0; + + if (planHasSubtasks && requireReviewBeforeCoding) { + // User wants to review before coding - STOP the task + // Note: spec_runner.py with --no-build already sets these values, but we set them + // here as a fallback in case the Python process didn't complete the update + // Also persist to worktree if it exists, since getTasks() prefers worktree version + const worktreePath = findTaskWorktree(exitProject.path, exitTask.specId); + const worktreeSpecDir = worktreePath ? path.join(worktreePath, specsBaseDir, exitTask.specId) : null; + + // Prepare updated content + metadata.stoppedForPlanReview = true; + planContent.status = "stopped"; + planContent.planStatus = "awaiting_review"; + + const metadataJson = JSON.stringify(metadata, null, 2); + const planJson = JSON.stringify(planContent, null, 2); + + // Write to main project (atomic write to prevent corruption) + try { + atomicWriteFileSync(metadataPath, metadataJson); + } catch (err) { + console.error(`[Task ${taskId}] Failed to update task_metadata.json:`, err); + } + + try { + atomicWriteFileSync(planPath, planJson); + } catch (err) { + console.error(`[Task ${taskId}] Failed to update implementation_plan.json:`, err); + } + + // Also write to worktree if it exists (for consistency with getTasks()) + if (worktreeSpecDir) { + const worktreeMetadataPath = path.join(worktreeSpecDir, "task_metadata.json"); + const worktreePlanPath = path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + if (existsSync(worktreeMetadataPath)) { + atomicWriteFileSync(worktreeMetadataPath, metadataJson); + } + } catch (err) { + console.error(`[Task ${taskId}] Failed to update worktree task_metadata.json:`, err); + } + + try { + if (existsSync(worktreePlanPath)) { + atomicWriteFileSync(worktreePlanPath, planJson); + } + } catch (err) { + console.error(`[Task ${taskId}] Failed to update worktree implementation_plan.json:`, err); + } + } + + // Invalidate cache so UI reflects the new status + projectStore.invalidateTasksCache(exitProject.id); + + // Notify renderer of status change + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + "stopped" as TaskStatus, + exitProjectId + ); + + return; + } + + if (planHasSubtasks && !requireReviewBeforeCoding) { + // Auto-continue to coding phase + // First, update the plan file status to in_progress so UI stays consistent + planContent.status = "in_progress"; + planContent.planStatus = "in_progress"; + + const planJson = JSON.stringify(planContent, null, 2); + + // Write to main project (atomic write to prevent corruption) + try { + atomicWriteFileSync(planPath, planJson); + } catch (err) { + console.error(`[Task ${taskId}] Failed to update implementation_plan.json for coding transition:`, err); + } + + // Also write to worktree if it exists (for consistency with getTasks()) + const worktreePath = findTaskWorktree(exitProject.path, exitTask.specId); + if (worktreePath) { + const worktreePlanPath = path.join(worktreePath, specsBaseDir, exitTask.specId, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + try { + if (existsSync(worktreePlanPath)) { + atomicWriteFileSync(worktreePlanPath, planJson); + } + } catch (err) { + console.error(`[Task ${taskId}] Failed to update worktree implementation_plan.json for coding transition:`, err); + } + } + + // Invalidate cache so UI reflects the new status + projectStore.invalidateTasksCache(exitProject.id); + + // Start coding phase + const baseBranch = (exitTask.metadata?.baseBranch as string | undefined) || exitProject.settings?.mainBranch; + agentManager.startTaskExecution( + taskId, + exitProject.path, + exitTask.specId, + { + parallel: false, + workers: 1, + baseBranch, + useWorktree: exitTask.metadata?.useWorktree as boolean | undefined + } + ); + + // Notify renderer that task is continuing to coding phase + // This ensures UI shows in_progress status immediately + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + "in_progress" as TaskStatus, + exitProjectId + ); + + // FIX: Send execution progress event with 'coding' phase immediately + // This ensures the task card badge shows "Coding" instead of staying on "Planning" + // The Python process will also emit __EXEC_PHASE__:coding, but this provides + // immediate UI feedback during the process startup delay + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.TASK_EXECUTION_PROGRESS, + taskId, + { + phase: "coding" as ExecutionPhase, + phaseProgress: 0, + overallProgress: 25, // Planning complete (25%), starting coding + message: "Starting implementation...", + completedPhases: ["planning"] as ExecutionPhase[] + }, + exitProjectId + ); + + return; + } + } } let task: Task | undefined; diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 532e1db4e2..ac297a4656 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -526,6 +526,51 @@ export function registerSettingsHandlers( } ); + ipcMain.handle( + IPC_CHANNELS.SHELL_OPEN_PATH, + async (_, filePath: string): Promise> => { + try { + // Validate filePath input + if (!filePath || typeof filePath !== 'string' || filePath.trim() === '') { + return { + success: false, + error: 'File path is required and must be a non-empty string' + }; + } + + // Resolve to absolute path + const resolvedPath = path.resolve(filePath); + + // Verify path exists + if (!existsSync(resolvedPath)) { + return { + success: false, + error: `File does not exist: ${resolvedPath}` + }; + } + + // Open with system default application + const result = await shell.openPath(resolvedPath); + + // shell.openPath returns empty string on success, error message on failure + if (result) { + return { + success: false, + error: result + }; + } + + return { success: true, data: resolvedPath }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + return { + success: false, + error: `Failed to open file: ${errorMsg}` + }; + } + } + ); + // ============================================ // Auto-Build Source Environment Operations // ============================================ diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index 6c864e78b1..c87e05d045 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron'; import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants'; import type { IPCResult, TaskStartOptions, TaskStatus, ImageAttachment } from '../../../shared/types'; import path from 'path'; -import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'fs'; +import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; import { spawnSync, execFileSync } from 'child_process'; import { getToolPath } from '../../cli-tool-manager'; import { AgentManager } from '../../agent'; @@ -18,43 +18,7 @@ import { import { findTaskWorktree } from '../../worktree-paths'; import { projectStore } from '../../project-store'; import { getIsolatedGitEnv } from '../../utils/git-isolation'; - -/** - * Atomic file write to prevent TOCTOU race conditions. - * Writes to a temporary file first, then atomically renames to target. - * This ensures the target file is never in an inconsistent state. - */ -function atomicWriteFileSync(filePath: string, content: string): void { - const tempPath = `${filePath}.${process.pid}.tmp`; - try { - writeFileSync(tempPath, content, 'utf-8'); - renameSync(tempPath, filePath); - } catch (error) { - // Clean up temp file if rename failed - try { - unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } - throw error; - } -} - -/** - * Safe file read that handles missing files without TOCTOU issues. - * Returns null if file doesn't exist or can't be read. - */ -function safeReadFileSync(filePath: string): string | null { - try { - return readFileSync(filePath, 'utf-8'); - } catch (error) { - // ENOENT (file not found) is expected, other errors should be logged - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(`[safeReadFileSync] Error reading ${filePath}:`, error); - } - return null; - } -} +import { atomicWriteFileSync, safeReadFileSync } from '../../utils/file-utils'; /** * Helper function to check subtask completion status @@ -221,6 +185,23 @@ export function registerTaskExecutionHandlers( // Use default description } + // Clear stoppedForPlanReview flag if it was set (user is resuming after review) + if (task.metadata?.stoppedForPlanReview) { + const metadataPath = path.join(specDir, 'task_metadata.json'); + try { + const metadataContent = safeReadFileSync(metadataPath); + if (metadataContent) { + const metadata = JSON.parse(metadataContent); + metadata.stoppedForPlanReview = false; + // Use atomic write to prevent corruption on crash/interrupt + atomicWriteFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + console.warn('[TASK_START] Cleared stoppedForPlanReview flag'); + } + } catch (err) { + console.error('[TASK_START] Failed to clear stoppedForPlanReview flag:', err); + } + } + console.warn('[TASK_START] Starting task execution (no subtasks) for:', task.specId); // Start task execution which will create the implementation plan // Note: No parallel mode for planning phase - parallel only makes sense with multiple subtasks diff --git a/apps/frontend/src/main/utils/file-utils.ts b/apps/frontend/src/main/utils/file-utils.ts new file mode 100644 index 0000000000..d93f325de3 --- /dev/null +++ b/apps/frontend/src/main/utils/file-utils.ts @@ -0,0 +1,38 @@ +import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'fs'; + +/** + * Atomic file write to prevent TOCTOU race conditions. + * Writes to a temporary file first, then atomically renames to target. + * This ensures the target file is never in an inconsistent state. + */ +export function atomicWriteFileSync(filePath: string, content: string): void { + const tempPath = `${filePath}.${process.pid}.tmp`; + try { + writeFileSync(tempPath, content, 'utf-8'); + renameSync(tempPath, filePath); + } catch (error) { + // Clean up temp file if rename failed + try { + unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safe file read that handles missing files without TOCTOU issues. + * Returns null if file doesn't exist or can't be read. + */ +export function safeReadFileSync(filePath: string): string | null { + try { + return readFileSync(filePath, 'utf-8'); + } catch (error) { + // ENOENT (file not found) is expected, other errors should be logged + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`[safeReadFileSync] Error reading ${filePath}:`, error); + } + return null; + } +} diff --git a/apps/frontend/src/preload/api/modules/shell-api.ts b/apps/frontend/src/preload/api/modules/shell-api.ts index 1a395ffdb6..810963ffb3 100644 --- a/apps/frontend/src/preload/api/modules/shell-api.ts +++ b/apps/frontend/src/preload/api/modules/shell-api.ts @@ -8,6 +8,7 @@ import type { IPCResult } from '../../../shared/types'; export interface ShellAPI { openExternal: (url: string) => Promise; openTerminal: (dirPath: string) => Promise>; + openPath: (filePath: string) => Promise>; } /** @@ -17,5 +18,7 @@ export const createShellAPI = (): ShellAPI => ({ openExternal: (url: string): Promise => invokeIpc(IPC_CHANNELS.SHELL_OPEN_EXTERNAL, url), openTerminal: (dirPath: string): Promise> => - invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath) + invokeIpc(IPC_CHANNELS.SHELL_OPEN_TERMINAL, dirPath), + openPath: (filePath: string): Promise> => + invokeIpc(IPC_CHANNELS.SHELL_OPEN_PATH, filePath) }); diff --git a/apps/frontend/src/renderer/__tests__/task-order.test.ts b/apps/frontend/src/renderer/__tests__/task-order.test.ts index d6b0f1f465..4d399facd0 100644 --- a/apps/frontend/src/renderer/__tests__/task-order.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-order.test.ts @@ -32,6 +32,7 @@ function createTestTaskOrder(overrides: Partial = {}): TaskOrder human_review: [], pr_created: [], done: [], + stopped: [], ...overrides }; } @@ -92,6 +93,7 @@ describe('Task Order State Management', () => { it('should preserve all column orders', () => { const order = createTestTaskOrder({ backlog: ['task-1'], + stopped: ['task-7'], in_progress: ['task-2'], ai_review: ['task-3'], human_review: ['task-4'], @@ -102,6 +104,7 @@ describe('Task Order State Management', () => { useTaskStore.getState().setTaskOrder(order); expect(useTaskStore.getState().taskOrder?.backlog).toEqual(['task-1']); + expect(useTaskStore.getState().taskOrder?.stopped).toEqual(['task-7']); expect(useTaskStore.getState().taskOrder?.in_progress).toEqual(['task-2']); expect(useTaskStore.getState().taskOrder?.ai_review).toEqual(['task-3']); expect(useTaskStore.getState().taskOrder?.human_review).toEqual(['task-4']); @@ -245,6 +248,7 @@ describe('Task Order State Management', () => { expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + stopped: [], in_progress: [], ai_review: [], human_review: [], @@ -277,6 +281,7 @@ describe('Task Order State Management', () => { // Should fall back to empty order state expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + stopped: [], in_progress: [], ai_review: [], human_review: [], @@ -303,6 +308,7 @@ describe('Task Order State Management', () => { // Should fall back to empty order state expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + stopped: [], in_progress: [], ai_review: [], human_review: [], @@ -495,7 +501,8 @@ describe('Task Order State Management', () => { ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + stopped: [] } as TaskOrderState; useTaskStore.setState({ taskOrder: order }); @@ -588,6 +595,7 @@ describe('Task Order State Management', () => { // Empty string causes JSON.parse to throw - should fall back to empty order expect(useTaskStore.getState().taskOrder).toEqual({ backlog: [], + stopped: [], in_progress: [], ai_review: [], human_review: [], diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 9df2353070..cb956fdfb2 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -43,10 +43,13 @@ function isValidDropColumn(id: string): id is typeof TASK_STATUS_COLUMNS[number] /** * Get the visual column for a task status. * pr_created tasks are displayed in the 'done' column, so we map them accordingly. + * stopped tasks are displayed in the 'backlog' column. * This is used to compare visual positions during drag-and-drop operations. */ function getVisualColumn(status: TaskStatus): typeof TASK_STATUS_COLUMNS[number] { - return status === 'pr_created' ? 'done' : status; + if (status === 'pr_created') return 'done'; + if (status === 'stopped') return 'backlog'; + return status; } interface KanbanBoardProps { @@ -478,6 +481,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR const tasksByStatus = useMemo(() => { // Note: pr_created tasks are shown in the 'done' column since they're essentially complete + // Note: stopped tasks are shown in the 'backlog' column const grouped: Record = { backlog: [], in_progress: [], @@ -487,8 +491,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR }; filteredTasks.forEach((task) => { - // Map pr_created tasks to the done column - const targetColumn = task.status === 'pr_created' ? 'done' : task.status; + // Map pr_created tasks to the done column, stopped tasks to backlog + const targetColumn = getVisualColumn(task.status); if (grouped[targetColumn]) { grouped[targetColumn].push(task); } @@ -741,7 +745,8 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR ai_review: [], human_review: [], pr_created: [], - done: [] + done: [], + stopped: [] }; for (const status of Object.keys(taskOrder) as Array) { diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 71ce197179..1d70ed5a48 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -105,6 +105,7 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp prevTask.metadata?.complexity === nextTask.metadata?.complexity && prevTask.metadata?.archivedAt === nextTask.metadata?.archivedAt && prevTask.metadata?.prUrl === nextTask.metadata?.prUrl && + prevTask.metadata?.stoppedForPlanReview === nextTask.metadata?.stoppedForPlanReview && // Check if any subtask statuses changed (compare all subtasks) prevTask.subtasks.every((s, i) => s.status === nextTask.subtasks[i]?.status) ); @@ -529,6 +530,15 @@ export const TaskCard = memo(function TaskCard({ {task.metadata.securitySeverity} {t('metadata.severity')} )} + {/* Require review before coding - show only when stopped for plan review */} + {task.status === 'stopped' && task.metadata?.stoppedForPlanReview && ( + + {t('tasks:metadata.reviewRequired')} + + )} )} @@ -620,6 +630,16 @@ export const TaskCard = memo(function TaskCard({ {t('actions.archive')} + ) : task.status === 'stopped' ? ( + ) : (task.status === 'backlog' || task.status === 'in_progress') && ( @@ -70,7 +73,16 @@ export function TaskActions({ onClick={onStartStop} > - Resume Task + {t('tasks:actions.resumeTask')} + + ) : task.status === 'stopped' ? ( + ) : (task.status === 'backlog' || task.status === 'in_progress') && ( @@ -94,7 +106,7 @@ export function TaskActions({ {task.status === 'done' && (
- Task completed successfully + {t('tasks:actions.taskCompletedSuccessfully')}
)} @@ -107,7 +119,7 @@ export function TaskActions({ disabled={isRunning && !isStuck} > - Delete Task + {t('tasks:actions.deleteTask')} @@ -117,15 +129,19 @@ export function TaskActions({ - Delete Task + {t('dialogs:deleteTask.title')}

- Are you sure you want to delete "{task.title}"? + }} + />

- This action cannot be undone. All task files, including the spec, implementation plan, and any generated code will be permanently deleted from the project. + {t('dialogs:deleteTask.warningMessage')}

{deleteError && (

@@ -136,7 +152,7 @@ export function TaskActions({ - Cancel + {t('common:buttons.cancel')} { e.preventDefault(); @@ -148,12 +164,12 @@ export function TaskActions({ {isDeleting ? ( <> - Deleting... + {t('tasks:actions.deleting')} ) : ( <> - Delete Permanently + {t('tasks:actions.deletePermanently')} )} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index 11c6f1de7c..dc6a20278b 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -273,6 +273,18 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, ); } + if (task.status === 'stopped') { + return ( + + ); + } + if (task.status === 'backlog' || task.status === 'in_progress') { return (