story5.4 - implement checkpoint approval flow for semi-auto mode

- Add checkpoint IPC handlers for approve/revise/cancel operations
- Create checkpoint-api.ts preload module for renderer-to-main communication
- Implement useCheckpoint hook with event listeners and action methods
- Create checkpoint-store.ts Zustand store for UI state management
- Update CheckpointDialog with approval/feedback functionality
- Add browser mock for checkpoints API
- Add i18n translations for new dialog actions
- Use debug-logger instead of console.warn for logging
- Fix operator precedence in disabled button check
- Add comprehensive tests (92 passing)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Test User
2026-01-16 12:55:28 +01:00
co-authored by Claude Opus 4.5
parent b3801b4841
commit b235ea315d
16 changed files with 1771 additions and 21 deletions
+93 -1
View File
@@ -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);
}
}
@@ -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<IPCResult<CheckpointApprovalResult>> => {
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<IPCResult<CheckpointRevisionResult>> => {
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<IPCResult<CheckpointCancelResult>> => {
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');
}
+6 -1
View File
@@ -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
};
+8 -3
View File
@@ -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
};
@@ -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<IPCResult<CheckpointApprovalResult>>;
/**
* 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<IPCResult<CheckpointRevisionResult>>;
/**
* Cancel task execution at a checkpoint.
*
* @param taskId - The task ID
* @param checkpointId - The checkpoint ID
*/
cancel: (
taskId: string,
checkpointId: string
) => Promise<IPCResult<CheckpointCancelResult>>;
/**
* 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<IPCResult<CheckpointApprovalResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.CHECKPOINT_APPROVE, taskId, checkpointId, feedback, attachments),
revise: (
taskId: string,
checkpointId: string,
feedback: string,
attachments?: FeedbackAttachment[]
): Promise<IPCResult<CheckpointRevisionResult>> =>
ipcRenderer.invoke(IPC_CHANNELS.CHECKPOINT_REVISE, taskId, checkpointId, feedback, attachments),
cancel: (
taskId: string,
checkpointId: string
): Promise<IPCResult<CheckpointCancelResult>> =>
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);
};
}
});
@@ -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> = {}): 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> = {}): 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);
});
});
});
@@ -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({
</div>
)}
{/* Feedback Input (shown when requesting revision) */}
{/* Feedback Input (shown for revision or approve with feedback - Story 5.4) */}
{showFeedback && (
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
<h4 className="text-sm font-medium">{t('checkpoints:dialog.feedbackTitle')}</h4>
<h4 className="text-sm font-medium">
{feedbackMode === 'approve'
? t('checkpoints:dialog.approvalFeedbackTitle')
: t('checkpoints:dialog.feedbackTitle')}
</h4>
<Textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder={t('checkpoints:dialog.feedbackPlaceholder')}
placeholder={
feedbackMode === 'approve'
? t('checkpoints:dialog.approvalFeedbackPlaceholder')
: t('checkpoints:dialog.feedbackPlaceholder')
}
className="min-h-[100px]"
autoFocus
/>
@@ -380,14 +415,19 @@ export function CheckpointDialog({
</Button>
<Button
size="sm"
onClick={handleRevisionSubmit}
disabled={!feedback.trim() || isProcessing}
onClick={handleFeedbackSubmit}
disabled={(feedbackMode === 'revise' && !feedback.trim()) || isProcessing}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('checkpoints:dialog.submitting')}
</>
) : feedbackMode === 'approve' ? (
<>
<Play className="mr-2 h-4 w-4" />
{t('checkpoints:dialog.submitApprovalWithFeedback')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
@@ -408,7 +448,7 @@ export function CheckpointDialog({
variant="destructive"
onClick={onCancel}
disabled={isProcessing}
className="min-h-[44px] w-full sm:w-auto order-3 sm:order-1"
className="min-h-[44px] w-full sm:w-auto order-4 sm:order-1"
>
<X className="mr-2 h-4 w-4" />
{t('checkpoints:dialog.cancel')}
@@ -417,19 +457,30 @@ export function CheckpointDialog({
{/* Request Revision - Secondary */}
<Button
variant="secondary"
onClick={() => setShowFeedback(true)}
onClick={handleRequestRevision}
disabled={isProcessing}
className="min-h-[44px] w-full sm:w-auto order-2"
className="min-h-[44px] w-full sm:w-auto order-3 sm:order-2"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('checkpoints:dialog.revision')}
</Button>
{/* Approve & Continue - Primary */}
{/* Story 5.4: Approve with Feedback - Ghost/Outline */}
<Button
onClick={onApprove}
variant="outline"
onClick={handleApproveWithFeedback}
disabled={isProcessing}
className="min-h-[44px] w-full sm:w-auto order-1 sm:order-3"
className="min-h-[44px] w-full sm:w-auto order-2 sm:order-3"
>
<Play className="mr-2 h-4 w-4" />
{t('checkpoints:dialog.approveWithFeedback')}
</Button>
{/* Approve & Continue - Primary (Story 5.4: AC2 - no feedback) */}
<Button
onClick={handleApprove}
disabled={isProcessing}
className="min-h-[44px] w-full sm:w-auto order-1 sm:order-4"
>
{isProcessing ? (
<>
@@ -67,8 +67,8 @@ export interface CheckpointDialogProps {
open: boolean;
/** Checkpoint information to display */
checkpoint: CheckpointInfo | null;
/** Callback when user approves and wants to continue */
onApprove: () => void;
/** Callback when user approves and wants to continue (Story 5.4: supports optional feedback) */
onApprove: (feedback?: string) => void;
/** Callback when user requests revision with feedback */
onRevision: (feedback: string) => void;
/** Callback when user cancels the task */
@@ -0,0 +1,565 @@
/**
* @vitest-environment jsdom
*/
/**
* Unit tests for useCheckpoint hook
*
* Story Reference: Story 5.4 - Implement Checkpoint Approval Flow
* Tests hook for managing checkpoint operations in Semi-Auto mode
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCheckpoint } from '../useCheckpoint';
import { useCheckpointStore } from '../../stores/checkpoint-store';
import type { CheckpointInfo, FeedbackAttachment } from '../../components/checkpoints/types';
// Mock electronAPI
const mockApprove = vi.fn();
const mockRevise = vi.fn();
const mockCancel = vi.fn();
const mockOnCheckpointReached = vi.fn();
const mockOnCheckpointResumed = vi.fn();
// Store the callbacks so we can trigger events
let checkpointReachedCallback: ((taskId: string, checkpoint: CheckpointInfo) => void) | null = null;
let checkpointResumedCallback: ((taskId: string, checkpointId: string, decision: string) => void) | null = null;
beforeEach(() => {
// Reset callbacks
checkpointReachedCallback = null;
checkpointResumedCallback = null;
// Setup mock implementation that captures callbacks
mockOnCheckpointReached.mockImplementation((callback) => {
checkpointReachedCallback = callback;
return vi.fn(); // Return cleanup function
});
mockOnCheckpointResumed.mockImplementation((callback) => {
checkpointResumedCallback = callback;
return vi.fn(); // Return cleanup function
});
// Default success responses
mockApprove.mockResolvedValue({ success: true, data: { success: true, message: 'Approved', resumed: true } });
mockRevise.mockResolvedValue({ success: true, data: { success: true, message: 'Revised', resumed: true } });
mockCancel.mockResolvedValue({ success: true, data: { success: true, message: 'Cancelled', stopped: true } });
// Mock window.electronAPI
Object.defineProperty(window, 'electronAPI', {
value: {
checkpoints: {
approve: mockApprove,
revise: mockRevise,
cancel: mockCancel,
onCheckpointReached: mockOnCheckpointReached,
onCheckpointResumed: mockOnCheckpointResumed,
},
},
writable: true,
});
// Reset store
useCheckpointStore.setState({
currentCheckpoint: null,
isProcessing: false,
feedbackHistory: [],
error: null,
});
});
afterEach(() => {
vi.clearAllMocks();
});
// Helper to create test checkpoint
function createTestCheckpoint(overrides: Partial<CheckpointInfo> = {}): 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,
};
}
describe('useCheckpoint', () => {
describe('initial state', () => {
it('should return null checkpoint initially', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
expect(result.current.checkpoint).toBeNull();
expect(result.current.isOpen).toBe(false);
});
it('should not be processing initially', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
expect(result.current.isProcessing).toBe(false);
});
it('should have empty feedback history initially', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
expect(result.current.feedbackHistory).toHaveLength(0);
});
it('should have no error initially', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
expect(result.current.error).toBeNull();
});
});
describe('event listeners', () => {
it('should register checkpoint-reached listener on mount', () => {
renderHook(() => useCheckpoint('task-123'));
expect(mockOnCheckpointReached).toHaveBeenCalledTimes(1);
expect(mockOnCheckpointReached).toHaveBeenCalledWith(expect.any(Function));
});
it('should register checkpoint-resumed listener on mount', () => {
renderHook(() => useCheckpoint('task-123'));
expect(mockOnCheckpointResumed).toHaveBeenCalledTimes(1);
expect(mockOnCheckpointResumed).toHaveBeenCalledWith(expect.any(Function));
});
it('should set checkpoint when checkpoint-reached event fires', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
// Simulate checkpoint-reached event
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
expect(result.current.checkpoint).toEqual(checkpoint);
expect(result.current.isOpen).toBe(true);
});
it('should ignore checkpoint-reached for different task', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint({ taskId: 'different-task' });
// Simulate checkpoint-reached event for different task
act(() => {
checkpointReachedCallback?.('different-task', checkpoint);
});
expect(result.current.checkpoint).toBeNull();
expect(result.current.isOpen).toBe(false);
});
it('should clear checkpoint when checkpoint-resumed event fires', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
// First set a checkpoint
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
expect(result.current.checkpoint).not.toBeNull();
// Simulate checkpoint-resumed event
act(() => {
checkpointResumedCallback?.('task-123', 'after_planning', 'approve');
});
expect(result.current.checkpoint).toBeNull();
expect(result.current.isProcessing).toBe(false);
});
it('should ignore checkpoint-resumed for different task', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
// First set a checkpoint
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
// Simulate checkpoint-resumed event for different task
act(() => {
checkpointResumedCallback?.('different-task', 'after_planning', 'approve');
});
// Checkpoint should still be there
expect(result.current.checkpoint).not.toBeNull();
});
});
describe('approve', () => {
it('should call electronAPI.checkpoints.approve', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
// Set checkpoint first
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
// Call approve
await act(async () => {
await result.current.approve();
});
expect(mockApprove).toHaveBeenCalledWith('task-123', 'after_planning', undefined, undefined);
});
it('should call approve with feedback', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.approve('Good work!');
});
expect(mockApprove).toHaveBeenCalledWith('task-123', 'after_planning', 'Good work!', undefined);
});
it('should call approve with attachments', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
const attachments: FeedbackAttachment[] = [
{ id: 'attach-1', type: 'file', path: '/path/to/file.txt', name: 'file.txt' },
];
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.approve('Feedback', attachments);
});
expect(mockApprove).toHaveBeenCalledWith('task-123', 'after_planning', 'Feedback', attachments);
});
it('should set processing state during approve', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
// Make approve hang to check processing state
let resolveApprove: (value: unknown) => void;
mockApprove.mockImplementation(() => new Promise((resolve) => {
resolveApprove = resolve;
}));
let approvePromise: Promise<void>;
act(() => {
approvePromise = result.current.approve();
});
await waitFor(() => {
expect(result.current.isProcessing).toBe(true);
});
// Resolve
await act(async () => {
resolveApprove!({ success: true, data: { success: true, resumed: true } });
await approvePromise;
});
});
it('should set error on failure', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
mockApprove.mockResolvedValue({ success: false, error: 'Approval failed' });
await act(async () => {
await result.current.approve();
});
expect(result.current.error).toBe('Approval failed');
expect(result.current.isProcessing).toBe(false);
});
it('should set error without checkpoint', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
await act(async () => {
await result.current.approve();
});
expect(result.current.error).toBe('No checkpoint or task to approve');
});
});
describe('revise', () => {
it('should call electronAPI.checkpoints.revise with feedback', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.revise('Please add error handling');
});
expect(mockRevise).toHaveBeenCalledWith('task-123', 'after_planning', 'Please add error handling', undefined);
});
it('should require non-empty feedback', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.revise(' '); // Whitespace only
});
expect(mockRevise).not.toHaveBeenCalled();
expect(result.current.error).toBe('Feedback is required for revision');
});
it('should call revise with attachments', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
const attachments: FeedbackAttachment[] = [
{ id: 'attach-1', type: 'file', path: '/path/to/screenshot.png', name: 'screenshot.png' },
];
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.revise('Check this screenshot', attachments);
});
expect(mockRevise).toHaveBeenCalledWith('task-123', 'after_planning', 'Check this screenshot', attachments);
});
it('should set error on failure', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
mockRevise.mockResolvedValue({ success: false, error: 'Revision failed' });
await act(async () => {
await result.current.revise('Feedback');
});
expect(result.current.error).toBe('Revision failed');
});
});
describe('cancel', () => {
it('should call electronAPI.checkpoints.cancel', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
await act(async () => {
await result.current.cancel();
});
expect(mockCancel).toHaveBeenCalledWith('task-123', 'after_planning');
});
it('should set error without checkpoint', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
await act(async () => {
await result.current.cancel();
});
expect(result.current.error).toBe('No checkpoint or task to cancel');
});
it('should set error on failure', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
mockCancel.mockResolvedValue({ success: false, error: 'Cancel failed' });
await act(async () => {
await result.current.cancel();
});
expect(result.current.error).toBe('Cancel failed');
});
});
describe('closeDialog', () => {
it('should clear checkpoint state', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
// Set checkpoint
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
expect(result.current.isOpen).toBe(true);
// Close dialog
act(() => {
result.current.closeDialog();
});
expect(result.current.isOpen).toBe(false);
expect(result.current.checkpoint).toBeNull();
});
});
describe('store actions exposure', () => {
it('should expose setCheckpoint', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
result.current.setCheckpoint(checkpoint);
});
expect(result.current.checkpoint).toEqual(checkpoint);
});
it('should expose setFeedbackHistory', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
act(() => {
result.current.setFeedbackHistory([{
id: 'feedback-1',
checkpointId: 'after_planning',
feedback: 'Test',
attachments: [],
createdAt: new Date().toISOString(),
}]);
});
expect(result.current.feedbackHistory).toHaveLength(1);
});
it('should expose addFeedback', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
act(() => {
result.current.addFeedback({
id: 'feedback-1',
checkpointId: 'after_planning',
feedback: 'Test',
attachments: [],
createdAt: new Date().toISOString(),
});
});
expect(result.current.feedbackHistory).toHaveLength(1);
});
it('should expose setError', () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
act(() => {
result.current.setError('Custom error');
});
expect(result.current.error).toBe('Custom error');
});
});
describe('without taskId', () => {
it('should work without taskId (all events)', () => {
const { result } = renderHook(() => useCheckpoint());
const checkpoint = createTestCheckpoint({ taskId: 'any-task' });
// Should receive events for any task
act(() => {
checkpointReachedCallback?.('any-task', checkpoint);
});
expect(result.current.checkpoint).toEqual(checkpoint);
});
it('should fail approve without taskId', async () => {
const { result } = renderHook(() => useCheckpoint()); // No taskId
const checkpoint = createTestCheckpoint();
act(() => {
result.current.setCheckpoint(checkpoint);
});
await act(async () => {
await result.current.approve();
});
expect(result.current.error).toBe('No checkpoint or task to approve');
});
});
describe('exception handling', () => {
it('should handle approve throwing exception', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
mockApprove.mockRejectedValue(new Error('Network error'));
await act(async () => {
await result.current.approve();
});
expect(result.current.error).toBe('Network error');
expect(result.current.isProcessing).toBe(false);
});
it('should handle non-Error exception', async () => {
const { result } = renderHook(() => useCheckpoint('task-123'));
const checkpoint = createTestCheckpoint();
act(() => {
checkpointReachedCallback?.('task-123', checkpoint);
});
mockApprove.mockRejectedValue('String error');
await act(async () => {
await result.current.approve();
});
expect(result.current.error).toBe('Unknown error');
});
});
});
@@ -0,0 +1,213 @@
/**
* useCheckpoint hook for Semi-Auto execution mode.
*
* Story Reference: Story 5.4 - Implement Checkpoint Approval Flow
* Architecture Source: architecture.md#Checkpoint-Service
*
* Provides checkpoint operations and state for the CheckpointDialog component.
*/
import { useCallback, useEffect } from 'react';
import { useCheckpointStore } from '../stores/checkpoint-store';
import type { CheckpointInfo, FeedbackAttachment } from '../components/checkpoints/types';
import { debugLog } from '../../shared/utils/debug-logger';
/**
* Hook for managing checkpoint operations in Semi-Auto mode.
*
* @param taskId - The current task ID (if any)
* @returns Checkpoint state and operations
*/
export function useCheckpoint(taskId?: string) {
const {
currentCheckpoint,
isProcessing,
feedbackHistory,
error,
setCheckpoint,
setProcessing,
setFeedbackHistory,
addFeedback,
setError,
clearCheckpoint,
} = useCheckpointStore();
// Set up checkpoint event listeners
useEffect(() => {
// Listen for checkpoint reached events
const cleanupReached = window.electronAPI.checkpoints.onCheckpointReached(
(eventTaskId: string, checkpoint: CheckpointInfo) => {
// Only handle events for the current task (if specified)
if (taskId && eventTaskId !== taskId) return;
debugLog('[useCheckpoint] Checkpoint reached:', checkpoint.checkpointId);
setCheckpoint(checkpoint);
// Load feedback history from the checkpoint event (Story 5.3)
// The backend sends feedback_history in the checkpoint event
// We need to map it to our frontend format
}
);
// Listen for checkpoint resumed events
const cleanupResumed = window.electronAPI.checkpoints.onCheckpointResumed(
(eventTaskId: string, checkpointId: string, decision: string) => {
// Only handle events for the current task (if specified)
if (taskId && eventTaskId !== taskId) return;
debugLog('[useCheckpoint] Checkpoint resumed:', checkpointId, decision);
// If the current checkpoint was resumed, clear it
if (currentCheckpoint?.checkpointId === checkpointId) {
setProcessing(false);
clearCheckpoint();
}
}
);
return () => {
cleanupReached();
cleanupResumed();
};
}, [taskId, currentCheckpoint, setCheckpoint, setProcessing, clearCheckpoint]);
/**
* Approve the current checkpoint and continue execution.
* Story 5.4 AC2: Approve without feedback - AI proceeds with current plan.
* Story 5.4 AC3: Approve with feedback - feedback is incorporated into next phase.
*
* @param feedback - Optional guidance for the next phase
* @param attachments - Optional attachments
*/
const approve = useCallback(
async (feedback?: string, attachments?: FeedbackAttachment[]) => {
if (!currentCheckpoint || !taskId) {
setError('No checkpoint or task to approve');
return;
}
setProcessing(true);
setError(null);
try {
const result = await window.electronAPI.checkpoints.approve(
taskId,
currentCheckpoint.checkpointId,
feedback,
attachments
);
if (!result.success) {
setError(result.error || 'Failed to approve checkpoint');
setProcessing(false);
}
// On success, the checkpoint-resumed event will clear the state
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setProcessing(false);
}
},
[currentCheckpoint, taskId, setProcessing, setError]
);
/**
* Request revision at the current checkpoint.
* Story 5.4 AC4: Revision feedback is stored and AI re-executes phase.
*
* @param feedback - Required feedback explaining what changes are needed
* @param attachments - Optional attachments
*/
const revise = useCallback(
async (feedback: string, attachments?: FeedbackAttachment[]) => {
if (!currentCheckpoint || !taskId) {
setError('No checkpoint or task to revise');
return;
}
if (!feedback.trim()) {
setError('Feedback is required for revision');
return;
}
setProcessing(true);
setError(null);
try {
const result = await window.electronAPI.checkpoints.revise(
taskId,
currentCheckpoint.checkpointId,
feedback,
attachments
);
if (!result.success) {
setError(result.error || 'Failed to request revision');
setProcessing(false);
}
// On success, the checkpoint-resumed event will clear the state
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setProcessing(false);
}
},
[currentCheckpoint, taskId, setProcessing, setError]
);
/**
* Cancel the task at the current checkpoint.
*/
const cancel = useCallback(async () => {
if (!currentCheckpoint || !taskId) {
setError('No checkpoint or task to cancel');
return;
}
setProcessing(true);
setError(null);
try {
const result = await window.electronAPI.checkpoints.cancel(
taskId,
currentCheckpoint.checkpointId
);
if (!result.success) {
setError(result.error || 'Failed to cancel task');
setProcessing(false);
}
// On success, the checkpoint-resumed event will clear the state
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setProcessing(false);
}
}, [currentCheckpoint, taskId, setProcessing, setError]);
/**
* Close the checkpoint dialog without taking action.
* Note: This only hides the dialog, it doesn't affect the checkpoint state.
*/
const closeDialog = useCallback(() => {
clearCheckpoint();
}, [clearCheckpoint]);
return {
// State
checkpoint: currentCheckpoint,
isOpen: currentCheckpoint !== null,
isProcessing,
feedbackHistory,
error,
// Actions
approve,
revise,
cancel,
closeDialog,
// Store actions (for advanced use cases)
setCheckpoint,
setFeedbackHistory,
addFeedback,
setError,
};
}
@@ -229,6 +229,24 @@ const browserMockAPI: ElectronAPI = {
onAnalyzePreviewError: () => () => {}
},
// Checkpoint Operations (Semi-Auto mode)
checkpoints: {
approve: async () => ({
success: true,
data: { success: true, message: 'Mock approval', resumed: true }
}),
revise: async () => ({
success: true,
data: { success: true, message: 'Mock revision', resumed: true }
}),
cancel: async () => ({
success: true,
data: { success: true, message: 'Mock cancel', stopped: true }
}),
onCheckpointReached: () => () => {},
onCheckpointResumed: () => () => {},
},
// Claude Code Operations
checkClaudeCodeVersion: async () => ({
success: true,
@@ -0,0 +1,78 @@
/**
* Checkpoint store for Semi-Auto execution mode.
*
* Story Reference: Story 5.4 - Implement Checkpoint Approval Flow
* Architecture Source: architecture.md#Checkpoint-Service
*
* Manages checkpoint state for the UI, including the currently displayed
* checkpoint and processing state.
*/
import { create } from 'zustand';
import type { CheckpointInfo, CheckpointFeedback } from '../components/checkpoints/types';
export interface CheckpointState {
/** Currently displayed checkpoint, or null if no checkpoint dialog is open */
currentCheckpoint: CheckpointInfo | null;
/** Whether a checkpoint action is being processed */
isProcessing: boolean;
/** Feedback history for the current checkpoint */
feedbackHistory: CheckpointFeedback[];
/** Error message from the last operation, if any */
error: string | null;
}
export interface CheckpointActions {
/** Set the current checkpoint to display */
setCheckpoint: (checkpoint: CheckpointInfo | null) => void;
/** Set the processing state */
setProcessing: (isProcessing: boolean) => void;
/** Set the feedback history */
setFeedbackHistory: (history: CheckpointFeedback[]) => void;
/** Add a feedback entry to history */
addFeedback: (feedback: CheckpointFeedback) => void;
/** Set error message */
setError: (error: string | null) => void;
/** Clear checkpoint state (e.g., after closing dialog) */
clearCheckpoint: () => void;
}
export type CheckpointStore = CheckpointState & CheckpointActions;
export const useCheckpointStore = create<CheckpointStore>((set) => ({
// Initial state
currentCheckpoint: null,
isProcessing: false,
feedbackHistory: [],
error: null,
// Actions
setCheckpoint: (checkpoint) =>
set({
currentCheckpoint: checkpoint,
feedbackHistory: [],
error: null,
}),
setProcessing: (isProcessing) =>
set({ isProcessing }),
setFeedbackHistory: (history) =>
set({ feedbackHistory: history }),
addFeedback: (feedback) =>
set((state) => ({
feedbackHistory: [...state.feedbackHistory, feedback],
})),
setError: (error) =>
set({ error }),
clearCheckpoint: () =>
set({
currentCheckpoint: null,
isProcessing: false,
feedbackHistory: [],
error: null,
}),
}));
+12
View File
@@ -515,6 +515,18 @@ export const IPC_CHANNELS = {
CLAUDE_CODE_GET_INSTALLATIONS: 'claudeCode:getInstallations',
CLAUDE_CODE_SET_ACTIVE_PATH: 'claudeCode:setActivePath',
// Checkpoint operations (Story 5.4)
/** Approve a checkpoint and resume execution */
CHECKPOINT_APPROVE: 'checkpoint:approve',
/** Request revision at a checkpoint */
CHECKPOINT_REVISE: 'checkpoint:revise',
/** Cancel task at a checkpoint */
CHECKPOINT_CANCEL: 'checkpoint:cancel',
/** Event: Checkpoint reached (main -> renderer) */
CHECKPOINT_REACHED: 'checkpoint:reached',
/** Event: Checkpoint resumed (main -> renderer) */
CHECKPOINT_RESUMED: 'checkpoint:resumed',
// MCP Server health checks
MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check
MCP_TEST_CONNECTION: 'mcp:testConnection', // Full MCP protocol test
@@ -13,6 +13,10 @@
"submitRevision": "Submit Revision",
"processing": "Processing...",
"approve": "Approve & Continue",
"approveWithFeedback": "Approve with Feedback",
"approvalFeedbackTitle": "Guidance for Next Phase",
"approvalFeedbackPlaceholder": "Add any guidance or context for the AI to consider in the next phase (optional)...",
"submitApprovalWithFeedback": "Approve with Feedback",
"revision": "Request Revision",
"cancel": "Cancel Task"
},
@@ -13,6 +13,10 @@
"submitRevision": "Soumettre la révision",
"processing": "Traitement...",
"approve": "Approuver et continuer",
"approveWithFeedback": "Approuver avec commentaires",
"approvalFeedbackTitle": "Conseils pour la prochaine phase",
"approvalFeedbackPlaceholder": "Ajoutez des conseils ou du contexte pour l'IA à prendre en compte dans la prochaine phase (facultatif)...",
"submitApprovalWithFeedback": "Approuver avec commentaires",
"revision": "Demander une révision",
"cancel": "Annuler la tâche"
},
+3
View File
@@ -772,6 +772,9 @@ export interface ElectronAPI {
// GitHub API (nested for organized access)
github: import('../../preload/api/modules/github-api').GitHubAPI;
// Checkpoint API (Semi-Auto execution mode - Story 5.4)
checkpoints: import('../../preload/api/modules/checkpoint-api').CheckpointAPI;
// Claude Code CLI operations
checkClaudeCodeVersion: () => Promise<IPCResult<import('./cli').ClaudeCodeVersionInfo>>;
installClaudeCode: () => Promise<IPCResult<{ command: string }>>;