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:
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user