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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-25 11:30:24 +01:00
committed by GitHub
parent 0299009dfc
commit 4937d57453
13 changed files with 683 additions and 53 deletions
@@ -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');
+118 -3
View File
@@ -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);
});
}
@@ -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<IPCResult> => {
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<IPCResult<PersistedRoadmapProgress | null>> => {
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<IPCResult> => {
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
// ============================================
@@ -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<IPCResult<Task>>;
// Progress persistence
saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise<IPCResult>;
loadRoadmapProgress: (projectId: string) => Promise<IPCResult<PersistedRoadmapProgress | null>>;
clearRoadmapProgress: (projectId: string) => Promise<IPCResult>;
// Event Listeners
onRoadmapProgress: (
callback: (projectId: string, status: RoadmapGenerationStatus) => void
@@ -80,6 +86,16 @@ export const createRoadmapAPI = (): RoadmapAPI => ({
): Promise<IPCResult<Task>> =>
invokeIpc(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId),
// Progress persistence
saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress): Promise<IPCResult> =>
invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_SAVE, projectId, progress),
loadRoadmapProgress: (projectId: string): Promise<IPCResult<PersistedRoadmapProgress | null>> =>
invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_LOAD, projectId),
clearRoadmapProgress: (projectId: string): Promise<IPCResult> =>
invokeIpc(IPC_CHANNELS.ROADMAP_PROGRESS_CLEAR, projectId),
// Event Listeners
onRoadmapProgress: (
callback: (projectId: string, status: RoadmapGenerationStatus) => void
@@ -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<RoadmapGenerationStatus['phase'], 'idle'>;
// 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 (
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="flex items-center gap-1.5 cursor-help"
animate={heartbeatAnimation}
transition={heartbeatTransition}
>
<div className={cn('h-2 w-2 rounded-full', color)} />
<span className="text-xs text-muted-foreground">{processingLabel}</span>
</motion.div>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
}
/**
* 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({
/>
</svg>
)}
{phase.label}
{t(phase.labelKey)}
</motion.div>
{index < STEP_PHASES.length - 1 && (
<div
@@ -195,9 +299,75 @@ export function RoadmapGenerationProgress({
className,
onStop
}: RoadmapGenerationProgressProps) {
const { phase, progress, message, error } = generationStatus;
const { t } = useTranslation('common');
const { phase, progress, message, error, startedAt, lastActivityAt } = generationStatus;
const reducedMotion = useReducedMotion();
const [isStopping, setIsStopping] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const [lastActivityDisplay, setLastActivityDisplay] = useState('');
/**
* Calculate elapsed time from startedAt timestamp
*/
const calculateElapsedTime = useCallback(() => {
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}
>
<Square className="h-4 w-4 mr-1" />
{isStopping ? 'Stopping...' : 'Stop'}
{isStopping ? t('roadmapProgress.stopping') : t('buttons.stop')}
</Button>
</TooltipTrigger>
<TooltipContent>Stop generation</TooltipContent>
<TooltipContent>{t('roadmapProgress.stopGeneration')}</TooltipContent>
</Tooltip>
</div>
)}
@@ -320,9 +490,9 @@ export function RoadmapGenerationProgress({
transition={{ duration: 0.2 }}
className="space-y-1"
>
<h3 className="text-lg font-semibold">{config.label}</h3>
<p className="text-sm text-muted-foreground">{config.description}</p>
{message && message !== config.description && (
<h3 className="text-lg font-semibold">{t(config.labelKey)}</h3>
<p className="text-sm text-muted-foreground">{t(config.descriptionKey)}</p>
{message && message !== t(config.descriptionKey) && (
<p className="text-xs text-muted-foreground mt-1">{message}</p>
)}
</motion.div>
@@ -333,8 +503,40 @@ export function RoadmapGenerationProgress({
{isActivePhase && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Progress</span>
<span className="text-xs font-medium">{progress}%</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t('roadmapProgress.progress')}</span>
{/* Elapsed time display */}
{startedAt && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span className="tabular-nums">{formatElapsedTime(elapsedTime)}</span>
</div>
)}
{/* Last activity display */}
{lastActivityDisplay && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground/70 cursor-help">
· {t('roadmapProgress.lastActivityPrefix')} {lastActivityDisplay}
</span>
</TooltipTrigger>
<TooltipContent>
{t('roadmapProgress.lastProgressUpdateTooltip')}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-3">
{/* Heartbeat indicator to show process is alive */}
<HeartbeatIndicator
isActive={isActivePhase}
reducedMotion={reducedMotion}
color={config.color}
processingLabel={t('roadmapProgress.processing')}
tooltipText={t('roadmapProgress.processActiveTooltip')}
/>
<span className="text-xs font-medium">{progress}%</span>
</div>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-border">
{progress > 0 ? (
@@ -358,7 +560,7 @@ export function RoadmapGenerationProgress({
)}
{/* Phase steps indicator */}
<PhaseStepsIndicator currentPhase={phase} reducedMotion={reducedMotion} />
<PhaseStepsIndicator currentPhase={phase} reducedMotion={reducedMotion} t={t} />
{/* Error display - shows whenever error is present, regardless of phase */}
<AnimatePresence mode="wait">
@@ -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: () => () => {},
@@ -87,7 +87,27 @@ export const useRoadmapStore = create<RoadmapState>((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<void> {
// 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({
@@ -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',
@@ -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',
@@ -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"
}
}
@@ -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"
}
}
+7 -1
View File
@@ -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<IPCResult<Task>>;
// Roadmap progress persistence
saveRoadmapProgress: (projectId: string, progress: PersistedRoadmapProgress) => Promise<IPCResult>;
loadRoadmapProgress: (projectId: string) => Promise<IPCResult<PersistedRoadmapProgress | null>>;
clearRoadmapProgress: (projectId: string) => Promise<IPCResult>;
// Roadmap event listeners
onRoadmapProgress: (
callback: (projectId: string, status: RoadmapGenerationStatus) => void
+14
View File
@@ -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;
}