From 4937d574532dc15f66f01390116ea593435ad4da Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:30:24 +0100 Subject: [PATCH] auto-claude: 148-add-progress-persistence-and-status-indicators (#1464) * auto-claude: subtask-1-1 - Extend RoadmapGenerationStatus type with startedAt and lastActivityAt * auto-claude: subtask-1-2 - Add IPC channels for progress persistence: ROADMAP_PROGRESS_SAVE, ROADMAP_PROGRESS_LOAD, ROADMAP_PROGRESS_CLEAR * auto-claude: subtask-1-3 - Add GENERATION_PROGRESS constant to AUTO_BUILD_PATHS * auto-claude: subtask-2-1 - Add IPC handlers for roadmap progress persistence Add three IPC handlers in roadmap-handlers.ts: - ROADMAP_PROGRESS_SAVE: Persist progress state to generation_progress.json - ROADMAP_PROGRESS_LOAD: Load persisted progress state from disk - ROADMAP_PROGRESS_CLEAR: Delete the progress file on completion/error/stop Follows existing patterns with snake_case JSON files and camelCase frontend. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-2-2 - Update agent-queue.ts to persist progress updates * auto-claude: subtask-3-1 - Add preload API methods for progress persistence Add saveRoadmapProgress, loadRoadmapProgress, and clearRoadmapProgress methods to RoadmapAPI interface and implementation. These methods use the IPC channels defined in subtask-1-2 to enable the renderer process to persist and restore roadmap generation state. Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-1 - Update loadRoadmap function to load persisted prog - Update loadRoadmap to load persisted progress via loadRoadmapProgress API - Restore startedAt and lastActivityAt timestamps when is_running is true - Add fallback with current timestamps when no persisted progress found - Add roadmap progress persistence methods to ElectronAPI interface - Add browser mock implementations for progress persistence methods Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-4-2 - Update setGenerationStatus action to include times Updated setGenerationStatus action in roadmap-store.ts to automatically manage timestamp fields: - Sets startedAt when transitioning from idle to active phase - Updates lastActivityAt on every status change during generation - Clears both timestamps when generation stops (idle/complete/error) - Preserves existing startedAt during active generation phases Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-5-1 - Add elapsed time display with formatElapsedTime utility - Add formatElapsedTime utility function for MM:SS and H:MM:SS formatting - Add elapsedTime state with useEffect interval for real-time updates - Display elapsed time with Clock icon next to progress indicator - Calculate elapsed time from RoadmapGenerationStatus.startedAt field - Use useCallback for memoized calculation function - Clean up interval on phase change or component unmount - Reset elapsed time when returning to idle phase Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-5-2 - Add last activity timestamp display with formatTimeAgo utility - Added formatTimeAgo utility function that formats timestamps into human-readable relative time strings (e.g., "just now", "5s ago", "2m ago", "1h ago") - Added lastActivityDisplay state with useEffect interval to update every 5 seconds - Display last activity timestamp next to elapsed time in progress bar section - Added tooltip explaining "Last progress update received" - Uses muted styling to differentiate from elapsed time Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-5-3 - Add heartbeat animation indicator that pulses subtly - Add HeartbeatIndicator component with subtle scale pulse (1.05x) animation - Show "Processing" status with animated dot to indicate process is alive - Respect useReducedMotion preference by disabling animation when enabled - Integrate indicator into progress bar section next to percentage display Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-6-1 - Add translation keys for roadmap progress UI text: - Add roadmapProgress section with elapsedTime, lastActivity, staleWarning keys - Add staleWarningTooltip with interpolation for minutes - Add French translations for all new keys Co-Authored-By: Claude Opus 4.5 * auto-claude: subtask-6-2 - Update RoadmapGenerationProgress to use translation keys - Add useTranslation hook from react-i18next - Convert hardcoded phase labels and descriptions to translation keys - Convert step labels to translation keys - Translate button text, tooltips, and progress labels - Add translation keys to en/common.json and fr/common.json - Pass translation function to child components Co-Authored-By: Claude Opus 4.5 * fix: preserve persisted timestamps when restoring roadmap progress state - Fix startedAt being overwritten with current time on reload by using status.startedAt ?? now when starting generation - Fix lastActivityAt always being overwritten by using status.lastActivityAt ?? now to preserve passed timestamps - Add documentation comment for SAVE/CLEAR IPC handlers explaining their purpose for API completeness Co-Authored-By: Claude Opus 4.5 * fix: align IPC progress types and add validation - Add PersistedRoadmapProgress type for IPC transport with string timestamps - Update loadRoadmapProgress return type to use PersistedRoadmapProgress - Remove unused isRunning field from persisted progress - Add validation for JSON structure before using parsed data Co-Authored-By: Claude Opus 4.5 * fix: validate phase value against allowed values when loading progress Add validation to ensure the phase field from persisted progress file matches one of the expected values (idle, analyzing, discovering, generating, complete, error). Prevents TypeError in frontend component when corrupted or manually edited files contain invalid phase values. Co-Authored-By: Claude Opus 4.5 * fix: align progress persistence types and add date validation - Update saveRoadmapProgress to use PersistedRoadmapProgress type - Derive isRunning from phase instead of requiring it as parameter - Add date validation when parsing persisted timestamps to handle corrupted date strings gracefully (returns current time as fallback) Co-Authored-By: Claude Opus 4.5 * test: increase subprocess-spawn test timeout for Windows CI Increase timeout from 15s to 30s for all subprocess spawn integration tests. Dynamic imports are slower on Windows CI, causing intermittent timeouts. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../integration/subprocess-spawn.test.ts | 22 +- apps/frontend/src/main/agent/agent-queue.ts | 121 +++++++- .../src/main/ipc-handlers/roadmap-handlers.ts | 145 +++++++++- .../src/preload/api/modules/roadmap-api.ts | 16 ++ .../components/RoadmapGenerationProgress.tsx | 260 ++++++++++++++++-- .../frontend/src/renderer/lib/browser-mock.ts | 5 + .../src/renderer/stores/roadmap-store.ts | 59 +++- apps/frontend/src/shared/constants/config.ts | 1 + apps/frontend/src/shared/constants/ipc.ts | 5 + .../src/shared/i18n/locales/en/common.json | 40 +++ .../src/shared/i18n/locales/fr/common.json | 40 +++ apps/frontend/src/shared/types/ipc.ts | 8 +- apps/frontend/src/shared/types/roadmap.ts | 14 + 13 files changed, 683 insertions(+), 53 deletions(-) diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index ff318463..0c459262 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -178,7 +178,7 @@ describe('Subprocess Spawn Integration', () => { }) }) ); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should spawn Python process for task execution', async () => { const { spawn } = await import('child_process'); @@ -207,7 +207,7 @@ describe('Subprocess Spawn Integration', () => { cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory }) ); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should spawn Python process for QA process', async () => { const { spawn } = await import('child_process'); @@ -237,7 +237,7 @@ describe('Subprocess Spawn Integration', () => { cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory }) ); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should accept parallel options without affecting spawn args', async () => { // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent @@ -268,7 +268,7 @@ describe('Subprocess Spawn Integration', () => { ]), expect.any(Object) ); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should emit log events from stdout', async () => { const { AgentManager } = await import('../../main/agent'); @@ -284,7 +284,7 @@ describe('Subprocess Spawn Integration', () => { mockStdout.emit('data', Buffer.from('Test log output\n')); expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n'); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should emit log events from stderr', async () => { const { AgentManager } = await import('../../main/agent'); @@ -300,7 +300,7 @@ describe('Subprocess Spawn Integration', () => { mockStderr.emit('data', Buffer.from('Progress: 50%\n')); expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n'); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should emit exit event when process exits', async () => { const { AgentManager } = await import('../../main/agent'); @@ -317,7 +317,7 @@ describe('Subprocess Spawn Integration', () => { // Exit event includes taskId, exit code, and process type expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String)); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should emit error event when process errors', async () => { const { AgentManager } = await import('../../main/agent'); @@ -333,7 +333,7 @@ describe('Subprocess Spawn Integration', () => { mockProcess.emit('error', new Error('Spawn failed')); expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed'); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should kill task and remove from tracking', async () => { const { AgentManager } = await import('../../main/agent'); @@ -354,7 +354,7 @@ describe('Subprocess Spawn Integration', () => { expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); } expect(manager.isRunning('task-1')).toBe(false); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should return false when killing non-existent task', async () => { const { AgentManager } = await import('../../main/agent'); @@ -363,7 +363,7 @@ describe('Subprocess Spawn Integration', () => { const result = manager.killTask('nonexistent'); expect(result).toBe(false); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should track running tasks', async () => { const { AgentManager } = await import('../../main/agent'); @@ -406,7 +406,7 @@ describe('Subprocess Spawn Integration', () => { expect.any(Array), expect.any(Object) ); - }, 15000); // Increase timeout for Windows CI + }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) it('should kill all running tasks', async () => { const { AgentManager } = await import('../../main/agent'); diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index 279ecc2b..785f4330 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -1,12 +1,13 @@ import { spawn } from 'child_process'; import path from 'path'; -import { existsSync, promises as fsPromises } from 'fs'; +import { existsSync, writeFileSync, mkdirSync, unlinkSync, promises as fsPromises } from 'fs'; import { EventEmitter } from 'events'; import { AgentState } from './agent-state'; import { AgentEvents } from './agent-events'; import { AgentProcessManager } from './agent-process'; import { RoadmapConfig } from './types'; import type { IdeationConfig, Idea } from '../../shared/types'; +import { AUTO_BUILD_PATHS } from '../../shared/constants'; import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; import { getOAuthModeClearVars } from './env-utils'; @@ -77,6 +78,73 @@ export class AgentQueueManager { return true; } + /** + * Persist roadmap generation progress to disk. + * Creates generation_progress.json with current state including timestamps. + * + * @param projectPath - The project directory path + * @param phase - Current generation phase + * @param progress - Progress percentage (0-100) + * @param message - Status message + * @param startedAt - When generation started (ISO string) + * @param isRunning - Whether generation is actively running + */ + private persistRoadmapProgress( + projectPath: string, + phase: string, + progress: number, + message: string, + startedAt: string, + isRunning: boolean + ): void { + try { + const roadmapDir = path.join(projectPath, AUTO_BUILD_PATHS.ROADMAP_DIR); + const progressPath = path.join(roadmapDir, AUTO_BUILD_PATHS.GENERATION_PROGRESS); + + // Ensure roadmap directory exists + if (!existsSync(roadmapDir)) { + mkdirSync(roadmapDir, { recursive: true }); + } + + const progressData = { + phase, + progress, + message, + started_at: startedAt, + last_update_at: new Date().toISOString(), + is_running: isRunning + }; + + writeFileSync(progressPath, JSON.stringify(progressData, null, 2)); + debugLog('[Agent Queue] Persisted roadmap progress:', { phase, progress }); + } catch (err) { + debugError('[Agent Queue] Failed to persist roadmap progress:', err); + } + } + + /** + * Clear roadmap generation progress file from disk. + * Called when generation completes, errors, or is stopped. + * + * @param projectPath - The project directory path + */ + private clearRoadmapProgress(projectPath: string): void { + try { + const progressPath = path.join( + projectPath, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.GENERATION_PROGRESS + ); + + if (existsSync(progressPath)) { + unlinkSync(progressPath); + debugLog('[Agent Queue] Cleared roadmap progress file'); + } + } catch (err) { + debugError('[Agent Queue] Failed to clear roadmap progress:', err); + } + } + /** * Start roadmap generation process * @@ -660,6 +728,18 @@ export class AgentQueueManager { let progressPercent = 10; // Collect output for rate limit detection let allRoadmapOutput = ''; + // Track startedAt timestamp for progress persistence + const roadmapStartedAt = new Date().toISOString(); + + // Persist initial progress state + this.persistRoadmapProgress( + projectPath, + progressPhase, + progressPercent, + 'Starting roadmap generation...', + roadmapStartedAt, + true + ); // Helper to emit logs - split multi-line output into individual log lines const emitLogs = (log: string) => { @@ -686,11 +766,24 @@ export class AgentQueueManager { progressPhase = progressUpdate.phase; progressPercent = progressUpdate.progress; + // Get status message for display + const statusMessage = formatStatusMessage(log); + + // Persist progress to disk for recovery after restart + this.persistRoadmapProgress( + projectPath, + progressPhase, + progressPercent, + statusMessage, + roadmapStartedAt, + true + ); + // Emit progress update this.emitter.emit('roadmap-progress', projectId, { phase: progressPhase, progress: progressPercent, - message: formatStatusMessage(log) + message: statusMessage }); }); @@ -701,10 +794,23 @@ export class AgentQueueManager { allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); console.error('[Roadmap STDERR]', log); emitLogs(log); + + const statusMessage = formatStatusMessage(log); + + // Persist progress to disk (also on stderr to show activity) + this.persistRoadmapProgress( + projectPath, + progressPhase, + progressPercent, + statusMessage, + roadmapStartedAt, + true + ); + this.emitter.emit('roadmap-progress', projectId, { phase: progressPhase, progress: progressPercent, - message: formatStatusMessage(log) + message: statusMessage }); }); @@ -717,6 +823,8 @@ export class AgentQueueManager { if (wasIntentionallyStopped) { debugLog('[Agent Queue] Roadmap process was intentionally stopped, ignoring exit'); this.state.clearKilledSpawn(spawnId); + // Clear progress file on intentional stop + this.clearRoadmapProgress(projectPath); // Note: Don't call deleteProcess here - killProcess() already deleted it. // A new process with the same projectId may have been started. return; @@ -748,6 +856,9 @@ export class AgentQueueManager { message: 'Roadmap generation complete' }); + // Clear progress file on successful completion + this.clearRoadmapProgress(projectPath); + // Load and emit the complete roadmap if (storedProjectPath) { try { @@ -794,6 +905,8 @@ export class AgentQueueManager { } } else { debugError('[Agent Queue] Roadmap generation failed:', { projectId, code }); + // Clear progress file on error + this.clearRoadmapProgress(projectPath); this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`); } }); @@ -802,6 +915,8 @@ export class AgentQueueManager { childProcess.on('error', (err: Error) => { console.error('[Roadmap] Process error:', err.message); this.state.deleteProcess(projectId); + // Clear progress file on process error + this.clearRoadmapProgress(projectPath); this.emitter.emit('roadmap-error', projectId, err.message); }); } diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index 2a4ae1ce..b8f11695 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -14,6 +14,7 @@ import type { RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, + PersistedRoadmapProgress, Task, TaskMetadata, CompetitorAnalysis, @@ -21,7 +22,7 @@ import type { } from "../../shared/types"; import type { RoadmapConfig } from "../agent/types"; import path from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs"; import { projectStore } from "../project-store"; import { AgentManager } from "../agent"; import { debugLog, debugError } from "../../shared/utils/debug-logger"; @@ -620,6 +621,148 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" } ); + // ============================================ + // Roadmap Progress Persistence + // Note: SAVE and CLEAR handlers are exposed for API completeness and future use. + // Currently, progress is saved internally by agent-queue.ts and cleared when + // generation completes. The LOAD handler is used by the renderer to restore + // persisted progress state on app restart or project switch. + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_PROGRESS_SAVE, + async ( + _, + projectId: string, + progressData: PersistedRoadmapProgress + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const roadmapDir = path.join(project.path, AUTO_BUILD_PATHS.ROADMAP_DIR); + const progressPath = path.join(roadmapDir, AUTO_BUILD_PATHS.GENERATION_PROGRESS); + + try { + // Ensure roadmap directory exists + if (!existsSync(roadmapDir)) { + mkdirSync(roadmapDir, { recursive: true }); + } + + // Derive isRunning from phase (active phases are running) + const isRunning = progressData.phase !== 'idle' && progressData.phase !== 'complete' && progressData.phase !== 'error'; + + // Transform camelCase to snake_case for JSON file + const fileData = { + phase: progressData.phase, + progress: progressData.progress, + message: progressData.message, + started_at: progressData.startedAt || new Date().toISOString(), + last_update_at: progressData.lastActivityAt || new Date().toISOString(), + is_running: isRunning, + }; + + writeFileSync(progressPath, JSON.stringify(fileData, null, 2)); + debugLog("[Roadmap Handler] Saved progress checkpoint:", { projectId, phase: progressData.phase }); + + return { success: true }; + } catch (error) { + debugError("[Roadmap Handler] Failed to save progress:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to save progress", + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_PROGRESS_LOAD, + async ( + _, + projectId: string + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const progressPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.GENERATION_PROGRESS + ); + + if (!existsSync(progressPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(progressPath, "utf-8"); + const rawData = JSON.parse(content); + + // Valid phase values that the frontend expects + const validPhases = ['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error']; + + // Validate required fields exist and phase is valid + if (!rawData.phase || typeof rawData.progress !== 'number' || !validPhases.includes(rawData.phase)) { + debugLog("[Roadmap Handler] Invalid progress file structure or phase, ignoring:", { projectId, phase: rawData.phase }); + return { success: true, data: null }; + } + + // Transform snake_case to camelCase for frontend + const progressData: PersistedRoadmapProgress = { + phase: rawData.phase, + progress: rawData.progress, + message: rawData.message || '', + startedAt: rawData.started_at, + lastActivityAt: rawData.last_update_at, + }; + + debugLog("[Roadmap Handler] Loaded progress checkpoint:", { projectId, phase: progressData.phase }); + + return { success: true, data: progressData }; + } catch (error) { + debugError("[Roadmap Handler] Failed to load progress:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to load progress", + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_PROGRESS_CLEAR, + async (_, projectId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: "Project not found" }; + } + + const progressPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.GENERATION_PROGRESS + ); + + try { + if (existsSync(progressPath)) { + unlinkSync(progressPath); + debugLog("[Roadmap Handler] Cleared progress checkpoint:", { projectId }); + } + return { success: true }; + } catch (error) { + debugError("[Roadmap Handler] Failed to clear progress:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Failed to clear progress", + }; + } + } + ); + // ============================================ // Roadmap Agent Events → Renderer // ============================================ diff --git a/apps/frontend/src/preload/api/modules/roadmap-api.ts b/apps/frontend/src/preload/api/modules/roadmap-api.ts index f5a9c423..f5543ed6 100644 --- a/apps/frontend/src/preload/api/modules/roadmap-api.ts +++ b/apps/frontend/src/preload/api/modules/roadmap-api.ts @@ -3,6 +3,7 @@ import type { Roadmap, RoadmapFeatureStatus, RoadmapGenerationStatus, + PersistedRoadmapProgress, Task, IPCResult } from '../../../shared/types'; @@ -29,6 +30,11 @@ export interface RoadmapAPI { featureId: string ) => Promise>; + // Progress persistence + saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise; + loadRoadmapProgress: (projectId: string) => Promise>; + clearRoadmapProgress: (projectId: string) => Promise; + // Event Listeners onRoadmapProgress: ( callback: (projectId: string, status: RoadmapGenerationStatus) => void @@ -80,6 +86,16 @@ export const createRoadmapAPI = (): RoadmapAPI => ({ ): Promise> => invokeIpc(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId), + // Progress persistence + saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress): Promise => + invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_SAVE, projectId, progress), + + loadRoadmapProgress: (projectId: string): Promise> => + invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_LOAD, projectId), + + clearRoadmapProgress: (projectId: string): Promise => + invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_CLEAR, projectId), + // Event Listeners onRoadmapProgress: ( callback: (projectId: string, status: RoadmapGenerationStatus) => void diff --git a/apps/frontend/src/renderer/components/RoadmapGenerationProgress.tsx b/apps/frontend/src/renderer/components/RoadmapGenerationProgress.tsx index 93f9b6a4..a67c9698 100644 --- a/apps/frontend/src/renderer/components/RoadmapGenerationProgress.tsx +++ b/apps/frontend/src/renderer/components/RoadmapGenerationProgress.tsx @@ -1,11 +1,60 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'motion/react'; -import { Search, Users, Sparkles, CheckCircle2, AlertCircle, Square } from 'lucide-react'; +import { Search, Users, Sparkles, CheckCircle2, AlertCircle, Square, Clock } from 'lucide-react'; import { Button } from './ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { cn } from '../lib/utils'; import type { RoadmapGenerationStatus } from '../../shared/types/roadmap'; +/** + * Formats elapsed time in seconds into a human-readable string. + * Examples: "0:05", "1:23", "12:05", "1:00:05" + * + * @param seconds - The elapsed time in seconds + * @returns Formatted time string (MM:SS or H:MM:SS for >= 1 hour) + */ +function formatElapsedTime(seconds: number): string { + if (seconds < 0) return '0:00'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Formats a timestamp into a human-readable relative time string. + * Examples: "just now", "5s ago", "2m ago", "1h ago" + * + * @param timestamp - The Date object or timestamp to format + * @returns Formatted relative time string + */ +function formatTimeAgo(timestamp: Date | string | undefined): string { + if (!timestamp) return ''; + + const date = timestamp instanceof Date ? timestamp : new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + + if (diffSecs < 5) return 'just now'; + if (diffSecs < 60) return `${diffSecs}s ago`; + + const diffMins = Math.floor(diffSecs / 60); + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + /** * Hook to detect user's reduced motion preference. * Listens for changes to the prefers-reduced-motion media query. @@ -44,48 +93,48 @@ interface RoadmapGenerationProgressProps { // Type for generation phases (excluding idle) type GenerationPhase = Exclude; -// Phase display configuration +// Phase display configuration (colors and icons only - labels are translated) const PHASE_CONFIG: Record< GenerationPhase, { - label: string; - description: string; + labelKey: string; + descriptionKey: string; icon: typeof Search; color: string; bgColor: string; } > = { analyzing: { - label: 'Analyzing', - description: 'Analyzing project structure and codebase...', + labelKey: 'roadmapProgress.phases.analyzing.label', + descriptionKey: 'roadmapProgress.phases.analyzing.description', icon: Search, color: 'bg-amber-500', bgColor: 'bg-amber-500/20', }, discovering: { - label: 'Discovering', - description: 'Discovering target audience and user needs...', + labelKey: 'roadmapProgress.phases.discovering.label', + descriptionKey: 'roadmapProgress.phases.discovering.description', icon: Users, color: 'bg-info', bgColor: 'bg-info/20', }, generating: { - label: 'Generating', - description: 'Generating feature roadmap...', + labelKey: 'roadmapProgress.phases.generating.label', + descriptionKey: 'roadmapProgress.phases.generating.description', icon: Sparkles, color: 'bg-primary', bgColor: 'bg-primary/20', }, complete: { - label: 'Complete', - description: 'Roadmap generation complete!', + labelKey: 'roadmapProgress.phases.complete.label', + descriptionKey: 'roadmapProgress.phases.complete.description', icon: CheckCircle2, color: 'bg-success', bgColor: 'bg-success/20', }, error: { - label: 'Error', - description: 'Generation failed', + labelKey: 'roadmapProgress.phases.error.label', + descriptionKey: 'roadmapProgress.phases.error.description', icon: AlertCircle, color: 'bg-destructive', bgColor: 'bg-destructive/20', @@ -93,21 +142,76 @@ const PHASE_CONFIG: Record< }; // Phases shown in the step indicator (excluding complete and error) -const STEP_PHASES: { key: GenerationPhase; label: string }[] = [ - { key: 'analyzing', label: 'Analyze' }, - { key: 'discovering', label: 'Discover' }, - { key: 'generating', label: 'Generate' }, +const STEP_PHASES: { key: GenerationPhase; labelKey: string }[] = [ + { key: 'analyzing', labelKey: 'roadmapProgress.steps.analyze' }, + { key: 'discovering', labelKey: 'roadmapProgress.steps.discover' }, + { key: 'generating', labelKey: 'roadmapProgress.steps.generate' }, ]; +/** + * Internal component for heartbeat animation indicator. + * Shows a subtle pulsing animation to indicate the process is alive. + * Respects user's reduced motion preference. + */ +function HeartbeatIndicator({ + isActive, + reducedMotion, + color, + processingLabel, + tooltipText, +}: { + isActive: boolean; + reducedMotion: boolean; + color: string; + processingLabel: string; + tooltipText: string; +}) { + if (!isActive) return null; + + // Heartbeat animation: subtle scale pulse to show process is alive + const heartbeatAnimation = reducedMotion + ? { scale: 1, opacity: 1 } + : { + scale: [1, 1.05, 1], + opacity: [0.7, 1, 0.7], + }; + + const heartbeatTransition = reducedMotion + ? { duration: 0 } + : { + duration: 2, + repeat: Infinity, + ease: 'easeInOut' as const, + }; + + return ( + + + +
+ {processingLabel} + + + {tooltipText} + + ); +} + /** * Internal component for showing phase steps indicator */ function PhaseStepsIndicator({ currentPhase, reducedMotion, + t, }: { currentPhase: RoadmapGenerationStatus['phase']; reducedMotion: boolean; + t: (key: string) => string; }) { const getPhaseState = ( phaseKey: GenerationPhase @@ -166,7 +270,7 @@ function PhaseStepsIndicator({ /> )} - {phase.label} + {t(phase.labelKey)} {index < STEP_PHASES.length - 1 && (
{ + if (!startedAt) return 0; + const startDate = startedAt instanceof Date ? startedAt : new Date(startedAt); + const now = new Date(); + return Math.floor((now.getTime() - startDate.getTime()) / 1000); + }, [startedAt]); + + /** + * Update elapsed time every second while generation is active + */ + useEffect(() => { + // Only track time for active phases (not idle, complete, or error) + const isActivePhase = phase !== 'idle' && phase !== 'complete' && phase !== 'error'; + + if (!isActivePhase || !startedAt) { + // Reset elapsed time when not active or no start time + if (phase === 'idle') { + setElapsedTime(0); + } + return; + } + + // Calculate initial elapsed time + setElapsedTime(calculateElapsedTime()); + + // Set up interval to update every second + const intervalId = setInterval(() => { + setElapsedTime(calculateElapsedTime()); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [phase, startedAt, calculateElapsedTime]); + + /** + * Update last activity display periodically for relative time + */ + useEffect(() => { + // Only track last activity for active phases + const isActivePhase = phase !== 'idle' && phase !== 'complete' && phase !== 'error'; + + if (!isActivePhase || !lastActivityAt) { + setLastActivityDisplay(''); + return; + } + + // Calculate initial display + setLastActivityDisplay(formatTimeAgo(lastActivityAt)); + + // Update every 5 seconds to keep relative time current + const intervalId = setInterval(() => { + setLastActivityDisplay(formatTimeAgo(lastActivityAt)); + }, 5000); + + return () => { + clearInterval(intervalId); + }; + }, [phase, lastActivityAt]); /** * Handle stop button click with error handling and double-click prevention @@ -281,10 +451,10 @@ export function RoadmapGenerationProgress({ disabled={isStopping} > - {isStopping ? 'Stopping...' : 'Stop'} + {isStopping ? t('roadmapProgress.stopping') : t('buttons.stop')} - Stop generation + {t('roadmapProgress.stopGeneration')}
)} @@ -320,9 +490,9 @@ export function RoadmapGenerationProgress({ transition={{ duration: 0.2 }} className="space-y-1" > -

{config.label}

-

{config.description}

- {message && message !== config.description && ( +

{t(config.labelKey)}

+

{t(config.descriptionKey)}

+ {message && message !== t(config.descriptionKey) && (

{message}

)} @@ -333,8 +503,40 @@ export function RoadmapGenerationProgress({ {isActivePhase && (
- Progress - {progress}% +
+ {t('roadmapProgress.progress')} + {/* Elapsed time display */} + {startedAt && ( +
+ + {formatElapsedTime(elapsedTime)} +
+ )} + {/* Last activity display */} + {lastActivityDisplay && ( + + + + · {t('roadmapProgress.lastActivityPrefix')} {lastActivityDisplay} + + + + {t('roadmapProgress.lastProgressUpdateTooltip')} + + + )} +
+
+ {/* Heartbeat indicator to show process is alive */} + + {progress}% +
{progress > 0 ? ( @@ -358,7 +560,7 @@ export function RoadmapGenerationProgress({ )} {/* Phase steps indicator */} - + {/* Error display - shows whenever error is present, regardless of phase */} diff --git a/apps/frontend/src/renderer/lib/browser-mock.ts b/apps/frontend/src/renderer/lib/browser-mock.ts index d9da2ff4..bef70985 100644 --- a/apps/frontend/src/renderer/lib/browser-mock.ts +++ b/apps/frontend/src/renderer/lib/browser-mock.ts @@ -90,6 +90,11 @@ const browserMockAPI: ElectronAPI = { stopRoadmap: async () => ({ success: true }), + // Roadmap Progress Persistence + saveRoadmapProgress: async () => ({ success: true }), + loadRoadmapProgress: async () => ({ success: true, data: null }), + clearRoadmapProgress: async () => ({ success: true }), + // Roadmap Event Listeners onRoadmapProgress: () => () => {}, onRoadmapComplete: () => () => {}, diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index c92e11c3..d1de4a5e 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -87,7 +87,27 @@ export const useRoadmapStore = create((set) => ({ setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }), - setGenerationStatus: (status) => set({ generationStatus: status }), + setGenerationStatus: (status) => + set((state) => { + const now = new Date(); + const isStartingGeneration = + state.generationStatus.phase === 'idle' && status.phase !== 'idle'; + const isStoppingGeneration = status.phase === 'idle' || status.phase === 'complete' || status.phase === 'error'; + + return { + generationStatus: { + ...status, + // Set startedAt when transitioning from idle to active, but preserve passed timestamp if provided (for restoring persisted state) + startedAt: isStartingGeneration + ? (status.startedAt ?? now) + : isStoppingGeneration + ? undefined + : status.startedAt ?? state.generationStatus.startedAt, + // Update lastActivityAt on any status change, but preserve passed timestamp if provided (for restoring persisted state) + lastActivityAt: isStoppingGeneration ? undefined : (status.lastActivityAt ?? now) + } + }; + }), setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }), @@ -253,13 +273,36 @@ export async function loadRoadmap(projectId: string): Promise { // This restores the generation status when switching back to a project const statusResult = await window.electronAPI.getRoadmapStatus(projectId); if (statusResult.success && statusResult.data?.isRunning) { - // Generation is running - restore the UI state to show progress - // The actual progress will be updated by incoming events - store.setGenerationStatus({ - phase: 'analyzing', - progress: 0, - message: 'Roadmap generation in progress...' - }); + // Generation is running - try to load persisted progress for more accurate state + const progressResult = await window.electronAPI.loadRoadmapProgress(projectId); + if (progressResult.success && progressResult.data) { + // Restore full progress state including timestamps + const persistedProgress = progressResult.data; + + // Helper to safely parse date strings (returns undefined for invalid dates) + const parseDate = (dateStr: string | undefined): Date | undefined => { + if (!dateStr) return undefined; + const date = new Date(dateStr); + return isNaN(date.getTime()) ? undefined : date; + }; + + store.setGenerationStatus({ + phase: persistedProgress.phase !== 'idle' ? persistedProgress.phase : 'analyzing', + progress: persistedProgress.progress, + message: persistedProgress.message || 'Roadmap generation in progress...', + startedAt: parseDate(persistedProgress.startedAt) ?? new Date(), + lastActivityAt: parseDate(persistedProgress.lastActivityAt) ?? new Date() + }); + } else { + // Fallback: generation is running but no persisted progress found + store.setGenerationStatus({ + phase: 'analyzing', + progress: 0, + message: 'Roadmap generation in progress...', + startedAt: new Date(), + lastActivityAt: new Date() + }); + } } else { // Generation is not running - reset to idle store.setGenerationStatus({ diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index e108a448..36729d05 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -97,6 +97,7 @@ export const AUTO_BUILD_PATHS = { SPEC_FILE: 'spec.md', QA_REPORT: 'qa_report.md', BUILD_PROGRESS: 'build-progress.txt', + GENERATION_PROGRESS: 'generation_progress.json', CONTEXT: 'context.json', REQUIREMENTS: 'requirements.json', ROADMAP_FILE: 'roadmap.json', diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index b5956492..98d9131c 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -182,6 +182,11 @@ export const IPC_CHANNELS = { ROADMAP_ERROR: 'roadmap:error', ROADMAP_STOPPED: 'roadmap:stopped', + // Roadmap progress persistence (per-project state) + ROADMAP_PROGRESS_SAVE: 'roadmap:progressSave', + ROADMAP_PROGRESS_LOAD: 'roadmap:progressLoad', + ROADMAP_PROGRESS_CLEAR: 'roadmap:progressClear', + // Context operations CONTEXT_GET: 'context:get', CONTEXT_REFRESH_INDEX: 'context:refreshIndex', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index 5b59e249..7c8a640a 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -566,5 +566,45 @@ "technicalDetails": "Technical details", "goToSettings": "Go to Settings" } + }, + "roadmapProgress": { + "elapsedTime": "Elapsed", + "lastActivity": "Last activity", + "staleWarning": "No activity for a while", + "staleWarningTooltip": "This task has had no activity for {{minutes}} minutes", + "phases": { + "analyzing": { + "label": "Analyzing", + "description": "Analyzing project structure and codebase..." + }, + "discovering": { + "label": "Discovering", + "description": "Discovering target audience and user needs..." + }, + "generating": { + "label": "Generating", + "description": "Generating feature roadmap..." + }, + "complete": { + "label": "Complete", + "description": "Roadmap generation complete!" + }, + "error": { + "label": "Error", + "description": "Generation failed" + } + }, + "steps": { + "analyze": "Analyze", + "discover": "Discover", + "generate": "Generate" + }, + "processing": "Processing", + "processActiveTooltip": "Process is actively running", + "stopGeneration": "Stop generation", + "stopping": "Stopping...", + "progress": "Progress", + "lastActivityPrefix": "last activity", + "lastProgressUpdateTooltip": "Last progress update received" } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index 7be7c00a..e090a832 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -566,5 +566,45 @@ "technicalDetails": "Détails techniques", "goToSettings": "Aller aux paramètres" } + }, + "roadmapProgress": { + "elapsedTime": "Écoulé", + "lastActivity": "Dernière activité", + "staleWarning": "Aucune activité depuis un moment", + "staleWarningTooltip": "Cette tâche n'a eu aucune activité depuis {{minutes}} minutes", + "phases": { + "analyzing": { + "label": "Analyse", + "description": "Analyse de la structure du projet et du code..." + }, + "discovering": { + "label": "Découverte", + "description": "Découverte du public cible et des besoins utilisateurs..." + }, + "generating": { + "label": "Génération", + "description": "Génération de la feuille de route..." + }, + "complete": { + "label": "Terminé", + "description": "Génération de la feuille de route terminée !" + }, + "error": { + "label": "Erreur", + "description": "La génération a échoué" + } + }, + "steps": { + "analyze": "Analyser", + "discover": "Découvrir", + "generate": "Générer" + }, + "processing": "En cours", + "processActiveTooltip": "Le processus est en cours d'exécution", + "stopGeneration": "Arrêter la génération", + "stopping": "Arrêt...", + "progress": "Progression", + "lastActivityPrefix": "dernière activité", + "lastProgressUpdateTooltip": "Dernière mise à jour de progression reçue" } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index f6c1111c..ad220016 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -106,7 +106,8 @@ import type { import type { Roadmap, RoadmapFeatureStatus, - RoadmapGenerationStatus + RoadmapGenerationStatus, + PersistedRoadmapProgress } from './roadmap'; import type { LinearTeam, @@ -376,6 +377,11 @@ export interface ElectronAPI { featureId: string ) => Promise>; + // Roadmap progress persistence + saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise; + loadRoadmapProgress: (projectId: string) => Promise>; + clearRoadmapProgress: (projectId: string) => Promise; + // Roadmap event listeners onRoadmapProgress: ( callback: (projectId: string, status: RoadmapGenerationStatus) => void diff --git a/apps/frontend/src/shared/types/roadmap.ts b/apps/frontend/src/shared/types/roadmap.ts index 573c3795..ac88884e 100644 --- a/apps/frontend/src/shared/types/roadmap.ts +++ b/apps/frontend/src/shared/types/roadmap.ts @@ -180,4 +180,18 @@ export interface RoadmapGenerationStatus { progress: number; message: string; error?: string; + startedAt?: Date; + lastActivityAt?: Date; +} + +/** + * Serialized version of RoadmapGenerationStatus for IPC transport. + * Timestamps are ISO strings since Date objects serialize as strings in JSON. + */ +export interface PersistedRoadmapProgress { + phase: RoadmapGenerationStatus['phase']; + progress: number; + message: string; + startedAt?: string; + lastActivityAt?: string; }