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:
co-authored by
Claude Opus 4.5
parent
b3801b4841
commit
b235ea315d
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 }>>;
|
||||
|
||||
Reference in New Issue
Block a user