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; }