diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 400ba21c..bb178590 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import path from 'path'; -import { existsSync } from 'fs'; +import { existsSync, writeFileSync, mkdirSync } from 'fs'; import { AgentState } from './agent-state'; import { AgentEvents } from './agent-events'; import { AgentProcessManager } from './agent-process'; @@ -12,6 +12,7 @@ import { RoadmapConfig } from './types'; import type { IdeationConfig } from '../../shared/types'; +import type { FeedbackAttachment } from '../../renderer/components/checkpoints/types'; /** * Main AgentManager - orchestrates agent process lifecycle @@ -458,4 +459,95 @@ export class AgentManager extends EventEmitter { return true; } + + // ============================================ + // Checkpoint Methods (Story 5.4) + // ============================================ + + /** + * Resume a checkpoint with the user's decision. + * + * Story 5.4: Records approval decision and signals the backend + * CheckpointService to resume execution. + * + * @param taskId - The task ID + * @param checkpointId - The checkpoint ID to resume + * @param decision - User's decision ('approve', 'revise', 'reject') + * @param feedback - Optional feedback text + * @param attachments - Optional file/link attachments + * @returns Result indicating success or failure + */ + async resumeCheckpoint( + taskId: string, + checkpointId: string, + decision: 'approve' | 'revise' | 'reject', + feedback?: string, + attachments?: FeedbackAttachment[] + ): Promise<{ success: boolean; error?: string }> { + console.warn(`[AgentManager] resumeCheckpoint: taskId=${taskId}, checkpointId=${checkpointId}, decision=${decision}`); + + // Get task context to find the spec directory + const context = this.taskExecutionContext.get(taskId); + if (!context) { + console.error('[AgentManager] No task context found for checkpoint resume'); + return { success: false, error: 'Task context not found' }; + } + + // Check if task is actually running + if (!this.isRunning(taskId)) { + console.error('[AgentManager] Task is not running'); + return { success: false, error: 'Task is not running' }; + } + + try { + // Write checkpoint decision to a control file that the backend CheckpointService monitors + // The backend will detect this file and call resume() with the decision + const specDir = context.specDir || path.join(context.projectPath, '.auto-claude', 'specs', context.specId); + + // Ensure the spec directory exists + if (!existsSync(specDir)) { + mkdirSync(specDir, { recursive: true }); + } + + const checkpointDecisionFile = path.join(specDir, 'checkpoint_decision.json'); + + const decisionData = { + checkpoint_id: checkpointId, + decision, + feedback: feedback || null, + attachments: attachments?.map(a => ({ + id: a.id, + type: a.type, + name: a.name, + path: a.path, + size: a.size, + mime_type: a.mimeType, + })) || [], + timestamp: new Date().toISOString(), + }; + + writeFileSync(checkpointDecisionFile, JSON.stringify(decisionData, null, 2)); + console.warn(`[AgentManager] Wrote checkpoint decision to: ${checkpointDecisionFile}`); + + return { success: true }; + } catch (error) { + console.error('[AgentManager] Error writing checkpoint decision:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error writing checkpoint decision', + }; + } + } + + /** + * Emit a checkpoint reached event. + * Called by the process manager when it detects a checkpoint event in the output. + * + * @param taskId - The task ID + * @param checkpointInfo - The checkpoint information + */ + emitCheckpointReached(taskId: string, checkpointInfo: unknown): void { + console.warn(`[AgentManager] emitCheckpointReached: taskId=${taskId}`); + this.emit('checkpoint-reached', taskId, checkpointInfo); + } } diff --git a/apps/frontend/src/main/ipc-handlers/checkpoint-handlers.ts b/apps/frontend/src/main/ipc-handlers/checkpoint-handlers.ts new file mode 100644 index 00000000..558f7c71 --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/checkpoint-handlers.ts @@ -0,0 +1,251 @@ +/** + * Checkpoint IPC handlers for Semi-Auto execution mode. + * + * Story Reference: Story 5.4 - Implement Checkpoint Approval Flow + * Architecture Source: architecture.md#Checkpoint-Service + * + * These handlers manage communication between the renderer process + * (CheckpointDialog) and the backend CheckpointService. + */ + +import { ipcMain, BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; +import type { + CheckpointApprovalResult, + CheckpointRevisionResult, + CheckpointCancelResult, +} from '../../preload/api/modules/checkpoint-api'; +import type { CheckpointInfo, FeedbackAttachment } from '../../renderer/components/checkpoints/types'; +import { AgentManager } from '../agent'; +import { safeSendToRenderer } from './utils'; +import { findTaskAndProject } from './task/shared'; +import { debugLog, debugError } from '../../shared/utils/debug-logger'; + +/** + * Register all checkpoint-related IPC handlers. + * + * @param agentManager - The agent manager instance for task communication + * @param getMainWindow - Function to get the main BrowserWindow + */ +export function registerCheckpointHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Checkpoint Decision Handlers (Renderer → Main) + // ============================================ + + /** + * Handle checkpoint approval. + * Story 5.4 FR25: Records approval and resumes execution. + */ + ipcMain.handle( + IPC_CHANNELS.CHECKPOINT_APPROVE, + async ( + _event, + taskId: string, + checkpointId: string, + feedback?: string, + attachments?: FeedbackAttachment[] + ): Promise> => { + debugLog(`[CHECKPOINT_APPROVE] taskId: ${taskId}, checkpointId: ${checkpointId}, hasFeedback: ${!!feedback}`); + + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { + success: false, + error: 'Task or project not found', + }; + } + + // Resume checkpoint in backend via agent manager + // Story 5.4: decision is 'approve', feedback is optional guidance + const result = await agentManager.resumeCheckpoint(taskId, checkpointId, 'approve', feedback, attachments); + + if (result.success) { + // Emit resumed event to renderer + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.CHECKPOINT_RESUMED, + taskId, + checkpointId, + 'approve' + ); + + return { + success: true, + data: { + success: true, + message: 'Checkpoint approved', + resumed: true, + }, + }; + } + + return { + success: false, + error: result.error || 'Failed to approve checkpoint', + }; + } catch (error) { + debugError('[CHECKPOINT_APPROVE] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error approving checkpoint', + }; + } + } + ); + + /** + * Handle checkpoint revision request. + * Story 5.4: Revision requires feedback explaining what changes are needed. + */ + ipcMain.handle( + IPC_CHANNELS.CHECKPOINT_REVISE, + async ( + _event, + taskId: string, + checkpointId: string, + feedback: string, + attachments?: FeedbackAttachment[] + ): Promise> => { + debugLog(`[CHECKPOINT_REVISE] taskId: ${taskId}, checkpointId: ${checkpointId}`); + + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { + success: false, + error: 'Task or project not found', + }; + } + + if (!feedback || !feedback.trim()) { + return { + success: false, + error: 'Feedback is required for revision requests', + }; + } + + // Resume checkpoint in backend via agent manager with revise decision + const result = await agentManager.resumeCheckpoint(taskId, checkpointId, 'revise', feedback, attachments); + + if (result.success) { + // Emit resumed event to renderer + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.CHECKPOINT_RESUMED, + taskId, + checkpointId, + 'revise' + ); + + return { + success: true, + data: { + success: true, + message: 'Revision requested', + resumed: true, + }, + }; + } + + return { + success: false, + error: result.error || 'Failed to request revision', + }; + } catch (error) { + debugError('[CHECKPOINT_REVISE] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error requesting revision', + }; + } + } + ); + + /** + * Handle checkpoint cancellation (cancel task at checkpoint). + */ + ipcMain.handle( + IPC_CHANNELS.CHECKPOINT_CANCEL, + async ( + _event, + taskId: string, + checkpointId: string + ): Promise> => { + debugLog(`[CHECKPOINT_CANCEL] taskId: ${taskId}, checkpointId: ${checkpointId}`); + + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { + success: false, + error: 'Task or project not found', + }; + } + + // Resume checkpoint with reject decision to cancel task + const result = await agentManager.resumeCheckpoint(taskId, checkpointId, 'reject'); + + if (result.success) { + // Emit resumed event to renderer with cancel decision + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.CHECKPOINT_RESUMED, + taskId, + checkpointId, + 'reject' + ); + + return { + success: true, + data: { + success: true, + message: 'Task cancelled at checkpoint', + stopped: true, + }, + }; + } + + return { + success: false, + error: result.error || 'Failed to cancel task', + }; + } catch (error) { + debugError('[CHECKPOINT_CANCEL] Error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error cancelling task', + }; + } + } + ); + + // ============================================ + // Checkpoint Event Forwarding (Agent Manager → Renderer) + // ============================================ + + /** + * Forward checkpoint reached events from agent manager to renderer. + * This event is emitted when the backend CheckpointService pauses at a checkpoint. + */ + agentManager.on('checkpoint-reached', (taskId: string, checkpoint: CheckpointInfo) => { + debugLog(`[checkpoint-reached] taskId: ${taskId}, checkpointId: ${checkpoint.checkpointId}`); + + // Get project ID for multi-project filtering + const { project } = findTaskAndProject(taskId); + + safeSendToRenderer( + getMainWindow, + IPC_CHANNELS.CHECKPOINT_REACHED, + taskId, + checkpoint, + project?.id + ); + }); + + debugLog('[IPC] Checkpoint handlers registered'); +} diff --git a/apps/frontend/src/main/ipc-handlers/index.ts b/apps/frontend/src/main/ipc-handlers/index.ts index b3ee5721..7b6f52e8 100644 --- a/apps/frontend/src/main/ipc-handlers/index.ts +++ b/apps/frontend/src/main/ipc-handlers/index.ts @@ -33,6 +33,7 @@ import { registerClaudeCodeHandlers } from './claude-code-handlers'; import { registerMcpHandlers } from './mcp-handlers'; import { registerProfileHandlers } from './profile-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; +import { registerCheckpointHandlers } from './checkpoint-handlers'; import { notificationService } from '../notification-service'; /** @@ -118,6 +119,9 @@ export function setupIpcHandlers( // API Profile handlers (custom Anthropic-compatible endpoints) registerProfileHandlers(); + // Checkpoint handlers (Semi-Auto execution mode - Story 5.4) + registerCheckpointHandlers(agentManager, getMainWindow); + console.warn('[IPC] All handler modules registered successfully'); } @@ -144,5 +148,6 @@ export { registerDebugHandlers, registerClaudeCodeHandlers, registerMcpHandlers, - registerProfileHandlers + registerProfileHandlers, + registerCheckpointHandlers }; diff --git a/apps/frontend/src/preload/api/index.ts b/apps/frontend/src/preload/api/index.ts index 5e01084a..b265cbab 100644 --- a/apps/frontend/src/preload/api/index.ts +++ b/apps/frontend/src/preload/api/index.ts @@ -13,6 +13,7 @@ import { DebugAPI, createDebugAPI } from './modules/debug-api'; import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api'; import { McpAPI, createMcpAPI } from './modules/mcp-api'; import { ProfileAPI, createProfileAPI } from './profile-api'; +import { CheckpointAPI, createCheckpointAPI } from './modules/checkpoint-api'; export interface ElectronAPI extends ProjectAPI, @@ -30,6 +31,7 @@ export interface ElectronAPI extends McpAPI, ProfileAPI { github: GitHubAPI; + checkpoints: CheckpointAPI; } export const createElectronAPI = (): ElectronAPI => ({ @@ -47,7 +49,8 @@ export const createElectronAPI = (): ElectronAPI => ({ ...createClaudeCodeAPI(), ...createMcpAPI(), ...createProfileAPI(), - github: createGitHubAPI() + github: createGitHubAPI(), + checkpoints: createCheckpointAPI() }); // Export individual API creators for potential use in tests or specialized contexts @@ -66,7 +69,8 @@ export { createGitLabAPI, createDebugAPI, createClaudeCodeAPI, - createMcpAPI + createMcpAPI, + createCheckpointAPI }; export type { @@ -84,5 +88,6 @@ export type { GitLabAPI, DebugAPI, ClaudeCodeAPI, - McpAPI + McpAPI, + CheckpointAPI }; diff --git a/apps/frontend/src/preload/api/modules/checkpoint-api.ts b/apps/frontend/src/preload/api/modules/checkpoint-api.ts new file mode 100644 index 00000000..e64d49e0 --- /dev/null +++ b/apps/frontend/src/preload/api/modules/checkpoint-api.ts @@ -0,0 +1,174 @@ +/** + * Checkpoint API module for Semi-Auto execution mode. + * + * Story Reference: Story 5.4 - Implement Checkpoint Approval Flow + * Architecture Source: architecture.md#Checkpoint-Service + */ + +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult } from '../../../shared/types'; +import type { CheckpointInfo, FeedbackAttachment } from '../../../renderer/components/checkpoints/types'; + +/** + * Result of a checkpoint approval operation. + */ +export interface CheckpointApprovalResult { + /** Whether the approval was successful */ + success: boolean; + /** Status message */ + message?: string; + /** Whether execution has resumed */ + resumed: boolean; +} + +/** + * Result of a checkpoint revision request. + */ +export interface CheckpointRevisionResult { + /** Whether the revision request was successful */ + success: boolean; + /** Status message */ + message?: string; + /** Whether execution has resumed with revision */ + resumed: boolean; +} + +/** + * Result of a checkpoint cancellation. + */ +export interface CheckpointCancelResult { + /** Whether the cancellation was successful */ + success: boolean; + /** Status message */ + message?: string; + /** Whether the task was stopped */ + stopped: boolean; +} + +/** + * Checkpoint API interface. + */ +export interface CheckpointAPI { + /** + * Approve a checkpoint and resume execution. + * Story 5.4 FR25: Records approval and resumes execution. + * + * @param taskId - The task ID + * @param checkpointId - The checkpoint ID + * @param feedback - Optional feedback to include with approval + * @param attachments - Optional attachments (Story 5.3) + */ + approve: ( + taskId: string, + checkpointId: string, + feedback?: string, + attachments?: FeedbackAttachment[] + ) => Promise>; + + /** + * Request revision at a checkpoint. + * + * @param taskId - The task ID + * @param checkpointId - The checkpoint ID + * @param feedback - Required feedback explaining the revision request + * @param attachments - Optional attachments (Story 5.3) + */ + revise: ( + taskId: string, + checkpointId: string, + feedback: string, + attachments?: FeedbackAttachment[] + ) => Promise>; + + /** + * Cancel task execution at a checkpoint. + * + * @param taskId - The task ID + * @param checkpointId - The checkpoint ID + */ + cancel: ( + taskId: string, + checkpointId: string + ) => Promise>; + + /** + * Listen for checkpoint reached events. + * + * @param callback - Called when a checkpoint is reached + * @returns Unsubscribe function + */ + onCheckpointReached: ( + callback: (taskId: string, checkpoint: CheckpointInfo) => void + ) => () => void; + + /** + * Listen for checkpoint resumed events. + * + * @param callback - Called when a checkpoint is resumed + * @returns Unsubscribe function + */ + onCheckpointResumed: ( + callback: (taskId: string, checkpointId: string, decision: string) => void + ) => () => void; +} + +/** + * Create the Checkpoint API. + */ +export const createCheckpointAPI = (): CheckpointAPI => ({ + approve: ( + taskId: string, + checkpointId: string, + feedback?: string, + attachments?: FeedbackAttachment[] + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHECKPOINT_APPROVE, taskId, checkpointId, feedback, attachments), + + revise: ( + taskId: string, + checkpointId: string, + feedback: string, + attachments?: FeedbackAttachment[] + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHECKPOINT_REVISE, taskId, checkpointId, feedback, attachments), + + cancel: ( + taskId: string, + checkpointId: string + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHECKPOINT_CANCEL, taskId, checkpointId), + + onCheckpointReached: ( + callback: (taskId: string, checkpoint: CheckpointInfo) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + checkpoint: CheckpointInfo + ): void => { + callback(taskId, checkpoint); + }; + ipcRenderer.on(IPC_CHANNELS.CHECKPOINT_REACHED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CHECKPOINT_REACHED, handler); + }; + }, + + onCheckpointResumed: ( + callback: (taskId: string, checkpointId: string, decision: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + checkpointId: string, + decision: string + ): void => { + callback(taskId, checkpointId, decision); + }; + ipcRenderer.on(IPC_CHANNELS.CHECKPOINT_RESUMED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CHECKPOINT_RESUMED, handler); + }; + } +}); diff --git a/apps/frontend/src/renderer/__tests__/checkpoint-store.test.ts b/apps/frontend/src/renderer/__tests__/checkpoint-store.test.ts new file mode 100644 index 00000000..3ec0bdda --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/checkpoint-store.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for Checkpoint Store + * + * Story Reference: Story 5.4 - Implement Checkpoint Approval Flow + * Tests Zustand store for checkpoint state management in Semi-Auto mode + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useCheckpointStore } from '../stores/checkpoint-store'; +import type { CheckpointInfo, CheckpointFeedback } from '../components/checkpoints/types'; + +// Helper to create test checkpoint +function createTestCheckpoint(overrides: Partial = {}): CheckpointInfo { + return { + checkpointId: 'after_planning', + name: 'Planning Review', + description: 'Review implementation plan before coding begins', + phase: 'planning', + taskId: 'task-123', + pausedAt: new Date().toISOString(), + artifacts: [], + decisions: [], + warnings: [], + requiresApproval: true, + summary: 'Test checkpoint', + ...overrides, + }; +} + +// Helper to create test feedback +function createTestFeedback(overrides: Partial = {}): CheckpointFeedback { + return { + id: `feedback-${Date.now()}`, + checkpointId: 'after_planning', + feedback: 'Please add more error handling', + attachments: [], + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('Checkpoint Store', () => { + beforeEach(() => { + // Reset store to initial state before each test + useCheckpointStore.setState({ + currentCheckpoint: null, + isProcessing: false, + feedbackHistory: [], + error: null, + }); + }); + + describe('initial state', () => { + it('should have null checkpoint initially', () => { + expect(useCheckpointStore.getState().currentCheckpoint).toBeNull(); + }); + + it('should not be processing initially', () => { + expect(useCheckpointStore.getState().isProcessing).toBe(false); + }); + + it('should have empty feedback history initially', () => { + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(0); + }); + + it('should have no error initially', () => { + expect(useCheckpointStore.getState().error).toBeNull(); + }); + }); + + describe('setCheckpoint', () => { + it('should set the current checkpoint', () => { + const checkpoint = createTestCheckpoint(); + + useCheckpointStore.getState().setCheckpoint(checkpoint); + + expect(useCheckpointStore.getState().currentCheckpoint).toEqual(checkpoint); + }); + + it('should clear feedback history when setting new checkpoint', () => { + // First add some feedback + useCheckpointStore.setState({ feedbackHistory: [createTestFeedback()] }); + + // Set new checkpoint + useCheckpointStore.getState().setCheckpoint(createTestCheckpoint()); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(0); + }); + + it('should clear error when setting new checkpoint', () => { + // First set an error + useCheckpointStore.setState({ error: 'Previous error' }); + + // Set new checkpoint + useCheckpointStore.getState().setCheckpoint(createTestCheckpoint()); + + expect(useCheckpointStore.getState().error).toBeNull(); + }); + + it('should allow setting checkpoint to null', () => { + useCheckpointStore.getState().setCheckpoint(createTestCheckpoint()); + useCheckpointStore.getState().setCheckpoint(null); + + expect(useCheckpointStore.getState().currentCheckpoint).toBeNull(); + }); + }); + + describe('setProcessing', () => { + it('should set processing to true', () => { + useCheckpointStore.getState().setProcessing(true); + + expect(useCheckpointStore.getState().isProcessing).toBe(true); + }); + + it('should set processing to false', () => { + useCheckpointStore.setState({ isProcessing: true }); + useCheckpointStore.getState().setProcessing(false); + + expect(useCheckpointStore.getState().isProcessing).toBe(false); + }); + }); + + describe('setFeedbackHistory', () => { + it('should set feedback history', () => { + const feedback = [createTestFeedback({ id: 'feedback-1' }), createTestFeedback({ id: 'feedback-2' })]; + + useCheckpointStore.getState().setFeedbackHistory(feedback); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(2); + expect(useCheckpointStore.getState().feedbackHistory[0].id).toBe('feedback-1'); + }); + + it('should replace existing feedback history', () => { + useCheckpointStore.setState({ feedbackHistory: [createTestFeedback({ id: 'old' })] }); + + useCheckpointStore.getState().setFeedbackHistory([createTestFeedback({ id: 'new' })]); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(1); + expect(useCheckpointStore.getState().feedbackHistory[0].id).toBe('new'); + }); + + it('should handle empty array', () => { + useCheckpointStore.setState({ feedbackHistory: [createTestFeedback()] }); + + useCheckpointStore.getState().setFeedbackHistory([]); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(0); + }); + }); + + describe('addFeedback', () => { + it('should add feedback to empty history', () => { + const feedback = createTestFeedback(); + + useCheckpointStore.getState().addFeedback(feedback); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(1); + expect(useCheckpointStore.getState().feedbackHistory[0]).toEqual(feedback); + }); + + it('should append feedback to existing history', () => { + useCheckpointStore.setState({ + feedbackHistory: [createTestFeedback({ id: 'first' })], + }); + + useCheckpointStore.getState().addFeedback(createTestFeedback({ id: 'second' })); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(2); + expect(useCheckpointStore.getState().feedbackHistory[1].id).toBe('second'); + }); + + it('should preserve existing feedback when adding', () => { + const firstFeedback = createTestFeedback({ id: 'first', feedback: 'First feedback' }); + const secondFeedback = createTestFeedback({ id: 'second', feedback: 'Second feedback' }); + + useCheckpointStore.getState().addFeedback(firstFeedback); + useCheckpointStore.getState().addFeedback(secondFeedback); + + expect(useCheckpointStore.getState().feedbackHistory[0].feedback).toBe('First feedback'); + expect(useCheckpointStore.getState().feedbackHistory[1].feedback).toBe('Second feedback'); + }); + }); + + describe('setError', () => { + it('should set error message', () => { + useCheckpointStore.getState().setError('Something went wrong'); + + expect(useCheckpointStore.getState().error).toBe('Something went wrong'); + }); + + it('should clear error when set to null', () => { + useCheckpointStore.setState({ error: 'Previous error' }); + + useCheckpointStore.getState().setError(null); + + expect(useCheckpointStore.getState().error).toBeNull(); + }); + }); + + describe('clearCheckpoint', () => { + it('should clear current checkpoint', () => { + useCheckpointStore.setState({ currentCheckpoint: createTestCheckpoint() }); + + useCheckpointStore.getState().clearCheckpoint(); + + expect(useCheckpointStore.getState().currentCheckpoint).toBeNull(); + }); + + it('should reset processing state', () => { + useCheckpointStore.setState({ isProcessing: true }); + + useCheckpointStore.getState().clearCheckpoint(); + + expect(useCheckpointStore.getState().isProcessing).toBe(false); + }); + + it('should clear feedback history', () => { + useCheckpointStore.setState({ feedbackHistory: [createTestFeedback()] }); + + useCheckpointStore.getState().clearCheckpoint(); + + expect(useCheckpointStore.getState().feedbackHistory).toHaveLength(0); + }); + + it('should clear error', () => { + useCheckpointStore.setState({ error: 'Some error' }); + + useCheckpointStore.getState().clearCheckpoint(); + + expect(useCheckpointStore.getState().error).toBeNull(); + }); + + it('should reset all state at once', () => { + // Set up full state + useCheckpointStore.setState({ + currentCheckpoint: createTestCheckpoint(), + isProcessing: true, + feedbackHistory: [createTestFeedback()], + error: 'Error message', + }); + + // Clear everything + useCheckpointStore.getState().clearCheckpoint(); + + // Verify all cleared + const state = useCheckpointStore.getState(); + expect(state.currentCheckpoint).toBeNull(); + expect(state.isProcessing).toBe(false); + expect(state.feedbackHistory).toHaveLength(0); + expect(state.error).toBeNull(); + }); + }); + + describe('store isolation', () => { + it('should not affect other state when setting checkpoint', () => { + useCheckpointStore.setState({ + isProcessing: true, + error: 'Existing error', + }); + + useCheckpointStore.getState().setCheckpoint(createTestCheckpoint()); + + // isProcessing should be unchanged (only error and feedbackHistory are reset) + expect(useCheckpointStore.getState().isProcessing).toBe(true); + }); + + it('should not affect checkpoint when setting error', () => { + const checkpoint = createTestCheckpoint(); + useCheckpointStore.setState({ currentCheckpoint: checkpoint }); + + useCheckpointStore.getState().setError('New error'); + + expect(useCheckpointStore.getState().currentCheckpoint).toEqual(checkpoint); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/checkpoints/CheckpointDialog.tsx b/apps/frontend/src/renderer/components/checkpoints/CheckpointDialog.tsx index fcf559bd..1a2f7764 100644 --- a/apps/frontend/src/renderer/components/checkpoints/CheckpointDialog.tsx +++ b/apps/frontend/src/renderer/components/checkpoints/CheckpointDialog.tsx @@ -285,22 +285,49 @@ export function CheckpointDialog({ const [expanded, setExpanded] = useState(false); const [showFeedback, setShowFeedback] = useState(false); const [feedback, setFeedback] = useState(''); + // Story 5.4: Track whether we're approving or revising + const [feedbackMode, setFeedbackMode] = useState<'approve' | 'revise'>('revise'); - // Handle revision submission - const handleRevisionSubmit = () => { - if (feedback.trim()) { + // Story 5.4: Handle feedback submission for both approve and revise modes + const handleFeedbackSubmit = () => { + if (feedbackMode === 'approve') { + // AC3: Approve with feedback - feedback is incorporated into next phase + onApprove(feedback.trim() || undefined); + setFeedback(''); + setShowFeedback(false); + } else if (feedback.trim()) { + // Revise mode requires feedback onRevision(feedback.trim()); setFeedback(''); setShowFeedback(false); } }; + // Story 5.4: Handle approve with optional feedback + const handleApprove = () => { + // AC2: Approve without feedback - AI proceeds with current plan + onApprove(undefined); + }; + + // Story 5.4: Show feedback input for approval with guidance + const handleApproveWithFeedback = () => { + setFeedbackMode('approve'); + setShowFeedback(true); + }; + + // Show feedback input for revision + const handleRequestRevision = () => { + setFeedbackMode('revise'); + setShowFeedback(true); + }; + // Reset state when dialog closes const handleOpenChange = (newOpen: boolean) => { if (!newOpen) { setExpanded(false); setShowFeedback(false); setFeedback(''); + setFeedbackMode('revise'); } onOpenChange(newOpen); }; @@ -355,14 +382,22 @@ export function CheckpointDialog({ )} - {/* Feedback Input (shown when requesting revision) */} + {/* Feedback Input (shown for revision or approve with feedback - Story 5.4) */} {showFeedback && (
-

{t('checkpoints:dialog.feedbackTitle')}

+

+ {feedbackMode === 'approve' + ? t('checkpoints:dialog.approvalFeedbackTitle') + : t('checkpoints:dialog.feedbackTitle')} +