diff --git a/apps/desktop/src/main/agent/agent-queue.ts b/apps/desktop/src/main/agent/agent-queue.ts index aada34a53f..8eae7579f0 100644 --- a/apps/desktop/src/main/agent/agent-queue.ts +++ b/apps/desktop/src/main/agent/agent-queue.ts @@ -5,7 +5,7 @@ import { AgentState } from './agent-state'; import type { AgentEvents } from './agent-events'; import { AgentProcessManager } from './agent-process'; import { RoadmapConfig } from './types'; -import type { IdeationConfig, Idea } from '../../shared/types'; +import type { IdeationConfig, Idea, RoadmapGenerationStatus } from '../../shared/types'; import { AUTO_BUILD_PATHS } from '../../shared/constants'; import { detectRateLimit, createSDKRateLimitInfo } from '../rate-limit-detector'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; @@ -21,6 +21,57 @@ import type { RoadmapStreamEvent } from '../ai/runners/roadmap'; import type { ModelShorthand, ThinkingLevel } from '../ai/config/types'; import { resolvePromptsDir } from '../ai/prompts/prompt-loader'; +type RoadmapPhase = RoadmapGenerationStatus['phase']; +const ROADMAP_HEARTBEAT_INTERVAL_MS = 3000; + +const ROADMAP_PHASE_PROGRESS_CAPS: Record = { + idle: 0, + analyzing: 25, + discovering: 55, + generating: 90, + complete: 100, + error: 100, +}; + +function getRoadmapHeartbeatMessage(phase: RoadmapPhase): string { + switch (phase) { + case 'analyzing': + return 'Analyzing project structure...'; + case 'discovering': + return 'Discovering target audience and user needs...'; + case 'generating': + return 'Generating roadmap features and phases...'; + case 'complete': + return 'Roadmap generation complete'; + case 'error': + return 'Roadmap generation failed'; + default: + return 'Starting roadmap generation...'; + } +} + +/** + * Normalize runner-emitted roadmap phase names to the canonical UI/XState phases. + * Supports legacy aliases emitted by the TS roadmap runner. + */ +function normalizeRoadmapPhase(rawPhase: string): RoadmapPhase { + switch (rawPhase) { + case 'idle': + case 'analyzing': + case 'discovering': + case 'generating': + case 'complete': + case 'error': + return rawPhase; + case 'discovery': + return 'discovering'; + case 'features': + return 'generating'; + default: + return 'analyzing'; + } +} + /** * Queue management for ideation and roadmap generation */ @@ -30,7 +81,7 @@ export class AgentQueueManager { private emitter: EventEmitter; private debouncedPersistRoadmapProgress: ( projectPath: string, - phase: string, + phase: RoadmapPhase, progress: number, message: string, startedAt: string, @@ -76,7 +127,7 @@ export class AgentQueueManager { */ private async persistRoadmapProgress( projectPath: string, - phase: string, + phase: RoadmapPhase, progress: number, message: string, startedAt: string, @@ -384,9 +435,10 @@ export class AgentQueueManager { }); // Track progress - let progressPhase = 'analyzing'; + let progressPhase: RoadmapPhase = 'analyzing'; let progressPercent = 10; const roadmapStartedAt = new Date().toISOString(); + let lastHeartbeatAt = 0; // Persist initial progress this.debouncedPersistRoadmapProgress( @@ -405,6 +457,34 @@ export class AgentQueueManager { message: 'Starting roadmap generation...' }); + const emitRoadmapHeartbeat = (message?: string, trickleProgress: boolean = false): void => { + const now = Date.now(); + if (now - lastHeartbeatAt < ROADMAP_HEARTBEAT_INTERVAL_MS) return; + lastHeartbeatAt = now; + + if (trickleProgress) { + const cap = ROADMAP_PHASE_PROGRESS_CAPS[progressPhase]; + if (progressPercent < cap) { + progressPercent = Math.min(progressPercent + 1, cap); + } + } + + const heartbeatMessage = message ?? getRoadmapHeartbeatMessage(progressPhase); + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: heartbeatMessage + }); + this.debouncedPersistRoadmapProgress( + projectPath, + progressPhase, + progressPercent, + heartbeatMessage, + roadmapStartedAt, + true + ); + }; + try { const result = await runRoadmapGeneration( { @@ -418,9 +498,9 @@ export class AgentQueueManager { (event: RoadmapStreamEvent) => { switch (event.type) { case 'phase-start': { - progressPhase = event.phase; + progressPhase = normalizeRoadmapPhase(event.phase); progressPercent = Math.min(progressPercent + 20, 90); - const msg = `Running ${event.phase} phase...`; + const msg = `Running ${progressPhase} phase...`; this.emitter.emit('roadmap-log', projectId, msg); this.emitter.emit('roadmap-progress', projectId, { phase: progressPhase, @@ -439,6 +519,13 @@ export class AgentQueueManager { } case 'text-delta': { this.emitter.emit('roadmap-log', projectId, event.text); + emitRoadmapHeartbeat(undefined, true); + break; + } + case 'tool-use': { + const msg = `Using tool: ${event.name}`; + this.emitter.emit('roadmap-log', projectId, msg); + emitRoadmapHeartbeat(msg, true); break; } case 'error': { diff --git a/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts b/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts index 3c17026a3c..3201282e7d 100644 --- a/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts @@ -33,6 +33,27 @@ function getFeatureSettings(): { model?: string; thinkingLevel?: string } { return getActiveProviderFeatureSettings('roadmap'); } +/** + * Normalize legacy roadmap phase aliases to canonical frontend phases. + */ +function normalizeRoadmapProgressPhase(rawPhase: string): RoadmapGenerationStatus['phase'] | null { + switch (rawPhase) { + case 'idle': + case 'analyzing': + case 'discovering': + case 'generating': + case 'complete': + case 'error': + return rawPhase; + case 'discovery': + return 'discovering'; + case 'features': + return 'generating'; + default: + return null; + } +} + /** * Register all roadmap-related IPC handlers */ @@ -705,18 +726,17 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" const content = await readFileWithRetry(progressPath, { encoding: "utf-8" }) as string; const rawData = JSON.parse(content); - // Valid phase values that the frontend expects - const validPhases = ['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error']; + const normalizedPhase = normalizeRoadmapProgressPhase(String(rawData.phase ?? '')); // Validate required fields exist and phase is valid - if (!rawData.phase || typeof rawData.progress !== 'number' || !validPhases.includes(rawData.phase)) { + if (!normalizedPhase || typeof rawData.progress !== 'number') { 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, + phase: normalizedPhase, progress: rawData.progress, message: rawData.message || '', startedAt: rawData.started_at, diff --git a/apps/desktop/src/renderer/stores/roadmap-store.ts b/apps/desktop/src/renderer/stores/roadmap-store.ts index 05a9fce067..a279023168 100644 --- a/apps/desktop/src/renderer/stores/roadmap-store.ts +++ b/apps/desktop/src/renderer/stores/roadmap-store.ts @@ -194,6 +194,28 @@ const initialGenerationStatus: RoadmapGenerationStatus = { message: '' }; +/** + * Normalize inbound phase aliases from backend/legacy progress files + * to the canonical roadmap generation phases expected by XState. + */ +function normalizeGenerationPhase(phase: string): RoadmapGenerationStatus['phase'] { + switch (phase) { + case 'idle': + case 'analyzing': + case 'discovering': + case 'generating': + case 'complete': + case 'error': + return phase; + case 'discovery': + return 'discovering'; + case 'features': + return 'generating'; + default: + return 'analyzing'; + } +} + /** * Derive RoadmapGenerationStatus from the generation actor's current snapshot. */ @@ -240,20 +262,27 @@ export const useRoadmapStore = create((set) => ({ setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }), setGenerationStatus: (status) => { + // Defensive normalization: runtime IPC payloads can contain legacy aliases + // ('discovery', 'features') even though the TS type is canonical. + const normalizedStatus: RoadmapGenerationStatus = { + ...status, + phase: normalizeGenerationPhase(status.phase as string) + }; + const actor = getOrCreateGenerationActor( - status.phase !== 'idle' ? status.phase : undefined, - status.phase !== 'idle' ? { - progress: status.progress, - message: status.message, - error: status.error, - startedAt: status.startedAt?.getTime(), - lastActivityAt: status.lastActivityAt?.getTime() + normalizedStatus.phase !== 'idle' ? normalizedStatus.phase : undefined, + normalizedStatus.phase !== 'idle' ? { + progress: normalizedStatus.progress, + message: normalizedStatus.message, + error: normalizedStatus.error, + startedAt: normalizedStatus.startedAt?.getTime(), + lastActivityAt: normalizedStatus.lastActivityAt?.getTime() } : undefined ); // Map the incoming status phase to an XState event let event: RoadmapGenerationEvent | null = null; - switch (status.phase) { + switch (normalizedStatus.phase) { case 'analyzing': { const currentState = String(actor.getSnapshot().value); if (currentState === 'idle') { @@ -324,7 +353,7 @@ export const useRoadmapStore = create((set) => ({ actor.send({ type: 'RESET' }); actor.send({ type: 'START_GENERATION' }); } - event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' }; + event = { type: 'GENERATION_ERROR', error: normalizedStatus.error ?? 'Unknown error' }; break; } case 'idle': { @@ -346,7 +375,11 @@ export const useRoadmapStore = create((set) => ({ // Send progress updates for active states const currentState = String(actor.getSnapshot().value); if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') { - actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message }); + actor.send({ + type: 'PROGRESS_UPDATE', + progress: normalizedStatus.progress, + message: normalizedStatus.message + }); } // Derive store state from the actor snapshot