Fix/ideation status sync (#212)
* fix(ideation): add missing event forwarders for status sync - Add event forwarders in ideation-handlers.ts for progress, log, type-complete, type-failed, complete, error, and stopped events - Fix ideation-type-complete to load actual ideas array from JSON files instead of emitting only the count Resolves UI getting stuck at 0/3 complete during ideation generation. * fix(ideation): fix UI not updating after actions - Fix getIdeationSummary to count only active ideas (exclude dismissed/archived) This ensures header stats match the visible ideas count - Add transformSessionFromSnakeCase to properly transform session data from backend snake_case to frontend camelCase on ideation-complete event - Transform raw session before emitting ideation-complete event Resolves header showing stale counts after dismissing/deleting ideas. * fix(ideation): improve type safety and async handling in ideation type completion - Replace synchronous readFileSync with async fsPromises.readFile in ideation-type-complete handler - Wrap async file read in IIFE with proper error handling to prevent unhandled promise rejections - Add type validation for IdeationType with VALID_IDEATION_TYPES set and isValidIdeationType guard - Add validateEnabledTypes function to filter out invalid type values and log dropped entries - Handle ENOENT separately * fix(ideation): improve generation state management and error handling - Add explicit isGenerating flag to prevent race conditions during async operations - Implement 5-minute timeout for generation with automatic cleanup and error state - Add ideation-stopped event emission when process is intentionally killed - Replace console.warn/error with proper ideation-error events in agent-queue - Add resetGeneratingTypes helper to transition all generating types to a target state - Filter out dismissed/ * refactor(ideation): improve event listener cleanup and timeout management - Extract event handler functions in ideation-handlers.ts to enable proper cleanup - Return cleanup function from registerIdeationHandlers to remove all listeners - Replace single generationTimeoutId with Map to support multiple concurrent projects - Add clearGenerationTimeout helper to centralize timeout cleanup logic - Extract loadIdeationType IIFE to named function for better error context - Enhance error logging with projectId, * refactor: use async file read for ideation and roadmap session loading - Replace synchronous readFileSync with async fsPromises.readFile - Prevents blocking the event loop during file operations - Consistent with async pattern used elsewhere in the codebase - Improved error handling with proper event emission * fix(agent-queue): improve roadmap completion handling and error reporting - Add transformRoadmapFromSnakeCase to convert backend snake_case to frontend camelCase - Transform raw roadmap data before emitting roadmap-complete event - Add roadmap-error emission for unexpected errors during completion - Add roadmap-error emission when project path is unavailable - Remove duplicate ideation-type-complete emission from error handler (event already emitted in loadIdeationType) - Update error log message
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { existsSync, 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 } from '../../shared/types';
|
||||
import type { IdeationConfig, Idea } from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { parsePythonCommand } from '../python-detector';
|
||||
import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ipc-handlers/ideation/transformers';
|
||||
import { transformRoadmapFromSnakeCase } from '../ipc-handlers/roadmap/transformers';
|
||||
import type { RawIdea } from '../ipc-handlers/ideation/types';
|
||||
|
||||
/**
|
||||
* Queue management for ideation and roadmap generation
|
||||
@@ -286,7 +289,6 @@ export class AgentQueueManager {
|
||||
// Emit all log lines for the activity log
|
||||
emitLogs(log);
|
||||
|
||||
// Check for streaming type completion signals
|
||||
const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/);
|
||||
if (typeCompleteMatch) {
|
||||
const [, ideationType, ideasCount] = typeCompleteMatch;
|
||||
@@ -299,8 +301,41 @@ export class AgentQueueManager {
|
||||
totalCompleted: completedTypes.size
|
||||
});
|
||||
|
||||
// Emit event for UI to load this type's ideas immediately
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10));
|
||||
const typeFilePath = path.join(
|
||||
projectPath,
|
||||
'.auto-claude',
|
||||
'ideation',
|
||||
`${ideationType}_ideas.json`
|
||||
);
|
||||
|
||||
const loadIdeationType = async (): Promise<void> => {
|
||||
try {
|
||||
const content = await fsPromises.readFile(typeFilePath, 'utf-8');
|
||||
const data: Record<string, RawIdea[]> = JSON.parse(content);
|
||||
const rawIdeas: RawIdea[] = data[ideationType] || [];
|
||||
const ideas: Idea[] = rawIdeas.map(transformIdeaFromSnakeCase);
|
||||
debugLog('[Agent Queue] Loaded ideas for type:', {
|
||||
ideationType,
|
||||
loadedCount: ideas.length,
|
||||
filePath: typeFilePath
|
||||
});
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, ideas);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
debugError('[Agent Queue] Ideas file not found:', typeFilePath);
|
||||
} else {
|
||||
debugError('[Agent Queue] Failed to load ideas for type:', ideationType, err);
|
||||
}
|
||||
this.emitter.emit('ideation-type-complete', projectId, ideationType, []);
|
||||
}
|
||||
};
|
||||
loadIdeationType().catch((err: unknown) => {
|
||||
debugError('[Agent Queue] Unhandled error in ideation type handler (event already emitted):', {
|
||||
ideationType,
|
||||
projectId,
|
||||
typeFilePath
|
||||
}, err);
|
||||
});
|
||||
}
|
||||
|
||||
const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/);
|
||||
@@ -357,6 +392,8 @@ export class AgentQueueManager {
|
||||
debugLog('[Agent Queue] Ideation process was intentionally stopped, ignoring exit');
|
||||
this.state.clearKilledSpawn(spawnId);
|
||||
this.state.deleteProcess(projectId);
|
||||
// Emit stopped event to ensure UI updates
|
||||
this.emitter.emit('ideation-stopped', projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,20 +434,38 @@ export class AgentQueueManager {
|
||||
);
|
||||
debugLog('[Agent Queue] Loading ideation session from:', ideationFilePath);
|
||||
if (existsSync(ideationFilePath)) {
|
||||
const content = readFileSync(ideationFilePath, 'utf-8');
|
||||
const session = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded ideation session:', {
|
||||
totalIdeas: session.ideas?.length || 0
|
||||
const loadSession = async (): Promise<void> => {
|
||||
try {
|
||||
const content = await fsPromises.readFile(ideationFilePath, 'utf-8');
|
||||
const rawSession = JSON.parse(content);
|
||||
const session = transformSessionFromSnakeCase(rawSession, projectId);
|
||||
debugLog('[Agent Queue] Loaded ideation session:', {
|
||||
totalIdeas: session.ideas?.length || 0
|
||||
});
|
||||
this.emitter.emit('ideation-complete', projectId, session);
|
||||
} catch (err) {
|
||||
debugError('[Ideation] Failed to load ideation session:', err);
|
||||
this.emitter.emit('ideation-error', projectId,
|
||||
`Failed to load ideation session: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
loadSession().catch((err: unknown) => {
|
||||
debugError('[Agent Queue] Unhandled error loading ideation session:', err);
|
||||
});
|
||||
this.emitter.emit('ideation-complete', projectId, session);
|
||||
} else {
|
||||
debugError('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
console.warn('[Ideation] ideation.json not found at:', ideationFilePath);
|
||||
this.emitter.emit('ideation-error', projectId,
|
||||
'Ideation completed but session file not found. Ideas may have been saved to individual type files.');
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Ideation] Failed to load ideation session:', err);
|
||||
console.error('[Ideation] Failed to load ideation session:', err);
|
||||
debugError('[Ideation] Unexpected error in ideation completion:', err);
|
||||
this.emitter.emit('ideation-error', projectId,
|
||||
`Failed to load ideation session: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
debugError('[Ideation] No project path available to load session');
|
||||
this.emitter.emit('ideation-error', projectId,
|
||||
'Ideation completed but project path unavailable');
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Ideation generation failed:', { projectId, code });
|
||||
@@ -605,21 +660,38 @@ export class AgentQueueManager {
|
||||
);
|
||||
debugLog('[Agent Queue] Loading roadmap from:', roadmapFilePath);
|
||||
if (existsSync(roadmapFilePath)) {
|
||||
const content = readFileSync(roadmapFilePath, 'utf-8');
|
||||
const roadmap = JSON.parse(content);
|
||||
debugLog('[Agent Queue] Loaded roadmap:', {
|
||||
featuresCount: roadmap.features?.length || 0,
|
||||
phasesCount: roadmap.phases?.length || 0
|
||||
const loadRoadmap = async (): Promise<void> => {
|
||||
try {
|
||||
const content = await fsPromises.readFile(roadmapFilePath, 'utf-8');
|
||||
const rawRoadmap = JSON.parse(content);
|
||||
const transformedRoadmap = transformRoadmapFromSnakeCase(rawRoadmap, projectId);
|
||||
debugLog('[Agent Queue] Loaded roadmap:', {
|
||||
featuresCount: transformedRoadmap.features?.length || 0,
|
||||
phasesCount: transformedRoadmap.phases?.length || 0
|
||||
});
|
||||
this.emitter.emit('roadmap-complete', projectId, transformedRoadmap);
|
||||
} catch (err) {
|
||||
debugError('[Roadmap] Failed to load roadmap:', err);
|
||||
this.emitter.emit('roadmap-error', projectId,
|
||||
`Failed to load roadmap: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
loadRoadmap().catch((err: unknown) => {
|
||||
debugError('[Agent Queue] Unhandled error loading roadmap:', err);
|
||||
});
|
||||
this.emitter.emit('roadmap-complete', projectId, roadmap);
|
||||
} else {
|
||||
debugError('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
console.warn('[Roadmap] roadmap.json not found at:', roadmapFilePath);
|
||||
this.emitter.emit('roadmap-error', projectId,
|
||||
'Roadmap completed but file not found.');
|
||||
}
|
||||
} catch (err) {
|
||||
debugError('[Roadmap] Failed to load roadmap:', err);
|
||||
console.error('[Roadmap] Failed to load roadmap:', err);
|
||||
debugError('[Roadmap] Unexpected error in roadmap completion:', err);
|
||||
this.emitter.emit('roadmap-error', projectId,
|
||||
`Unexpected error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
debugError('[Roadmap] No project path available for roadmap completion');
|
||||
this.emitter.emit('roadmap-error', projectId, 'Roadmap completed but project path not found.');
|
||||
}
|
||||
} else {
|
||||
debugError('[Agent Queue] Roadmap generation failed:', { projectId, code });
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { AgentManager } from '../agent';
|
||||
import type { IdeationGenerationStatus, IdeationSession, Idea } from '../../shared/types';
|
||||
import {
|
||||
getIdeationSession,
|
||||
updateIdeaStatus,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
export function registerIdeationHandlers(
|
||||
agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
): () => void {
|
||||
// Session management
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.IDEATION_GET,
|
||||
@@ -98,4 +99,75 @@ export function registerIdeationHandlers(
|
||||
IPC_CHANNELS.IDEATION_CONVERT_TO_TASK,
|
||||
convertIdeaToTask
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Ideation Agent Events → Renderer
|
||||
// ============================================
|
||||
|
||||
const handleIdeationProgress = (projectId: string, status: IdeationGenerationStatus): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_PROGRESS, projectId, status);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationLog = (projectId: string, log: string): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_LOG, projectId, log);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationTypeComplete = (projectId: string, ideationType: string, ideas: Idea[]): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, projectId, ideationType, ideas);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationTypeFailed = (projectId: string, ideationType: string): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_TYPE_FAILED, projectId, ideationType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationComplete = (projectId: string, session: IdeationSession): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_COMPLETE, projectId, session);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationError = (projectId: string, error: string): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_ERROR, projectId, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIdeationStopped = (projectId: string): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId);
|
||||
}
|
||||
};
|
||||
|
||||
agentManager.on('ideation-progress', handleIdeationProgress);
|
||||
agentManager.on('ideation-log', handleIdeationLog);
|
||||
agentManager.on('ideation-type-complete', handleIdeationTypeComplete);
|
||||
agentManager.on('ideation-type-failed', handleIdeationTypeFailed);
|
||||
agentManager.on('ideation-complete', handleIdeationComplete);
|
||||
agentManager.on('ideation-error', handleIdeationError);
|
||||
agentManager.on('ideation-stopped', handleIdeationStopped);
|
||||
|
||||
return (): void => {
|
||||
agentManager.off('ideation-progress', handleIdeationProgress);
|
||||
agentManager.off('ideation-log', handleIdeationLog);
|
||||
agentManager.off('ideation-type-complete', handleIdeationTypeComplete);
|
||||
agentManager.off('ideation-type-failed', handleIdeationTypeFailed);
|
||||
agentManager.off('ideation-complete', handleIdeationComplete);
|
||||
agentManager.off('ideation-error', handleIdeationError);
|
||||
agentManager.off('ideation-stopped', handleIdeationStopped);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,10 +11,45 @@ import type {
|
||||
SecurityHardeningIdea,
|
||||
PerformanceOptimizationIdea,
|
||||
CodeQualityIdea,
|
||||
IdeationStatus
|
||||
IdeationStatus,
|
||||
IdeationType,
|
||||
IdeationSession
|
||||
} from '../../../shared/types';
|
||||
import { debugLog } from '../../../shared/utils/debug-logger';
|
||||
import type { RawIdea } from './types';
|
||||
|
||||
const VALID_IDEATION_TYPES: ReadonlySet<IdeationType> = new Set([
|
||||
'code_improvements',
|
||||
'ui_ux_improvements',
|
||||
'documentation_gaps',
|
||||
'security_hardening',
|
||||
'performance_optimizations',
|
||||
'code_quality'
|
||||
] as const);
|
||||
|
||||
function isValidIdeationType(value: unknown): value is IdeationType {
|
||||
return typeof value === 'string' && VALID_IDEATION_TYPES.has(value as IdeationType);
|
||||
}
|
||||
|
||||
function validateEnabledTypes(rawTypes: unknown): IdeationType[] {
|
||||
if (!Array.isArray(rawTypes)) {
|
||||
return [];
|
||||
}
|
||||
const validTypes: IdeationType[] = [];
|
||||
const invalidTypes: unknown[] = [];
|
||||
for (const entry of rawTypes) {
|
||||
if (isValidIdeationType(entry)) {
|
||||
validTypes.push(entry);
|
||||
} else {
|
||||
invalidTypes.push(entry);
|
||||
}
|
||||
}
|
||||
if (invalidTypes.length > 0) {
|
||||
debugLog('[Transformers] Dropped invalid IdeationType values:', invalidTypes);
|
||||
}
|
||||
return validTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an idea from snake_case (Python backend) to camelCase (TypeScript frontend)
|
||||
*/
|
||||
@@ -145,3 +180,61 @@ export function transformIdeaFromSnakeCase(idea: RawIdea): Idea {
|
||||
implementationApproach: ''
|
||||
} as CodeImprovementIdea;
|
||||
}
|
||||
|
||||
interface RawIdeationSession {
|
||||
id?: string;
|
||||
project_id?: string;
|
||||
config?: {
|
||||
enabled_types?: string[];
|
||||
enabledTypes?: string[];
|
||||
include_roadmap_context?: boolean;
|
||||
includeRoadmapContext?: boolean;
|
||||
include_kanban_context?: boolean;
|
||||
includeKanbanContext?: boolean;
|
||||
max_ideas_per_type?: number;
|
||||
maxIdeasPerType?: number;
|
||||
};
|
||||
ideas?: RawIdea[];
|
||||
project_context?: {
|
||||
existing_features?: string[];
|
||||
tech_stack?: string[];
|
||||
target_audience?: string;
|
||||
planned_features?: string[];
|
||||
};
|
||||
projectContext?: {
|
||||
existingFeatures?: string[];
|
||||
techStack?: string[];
|
||||
targetAudience?: string;
|
||||
plannedFeatures?: string[];
|
||||
};
|
||||
generated_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function transformSessionFromSnakeCase(
|
||||
rawSession: RawIdeationSession,
|
||||
projectId: string
|
||||
): IdeationSession {
|
||||
const rawEnabledTypes = rawSession.config?.enabled_types || rawSession.config?.enabledTypes || [];
|
||||
const enabledTypes = validateEnabledTypes(rawEnabledTypes);
|
||||
|
||||
return {
|
||||
id: rawSession.id || `ideation-${Date.now()}`,
|
||||
projectId,
|
||||
config: {
|
||||
enabledTypes,
|
||||
includeRoadmapContext: rawSession.config?.include_roadmap_context ?? rawSession.config?.includeRoadmapContext ?? true,
|
||||
includeKanbanContext: rawSession.config?.include_kanban_context ?? rawSession.config?.includeKanbanContext ?? true,
|
||||
maxIdeasPerType: rawSession.config?.max_ideas_per_type || rawSession.config?.maxIdeasPerType || 5
|
||||
},
|
||||
ideas: (rawSession.ideas || []).map(idea => transformIdeaFromSnakeCase(idea)),
|
||||
projectContext: {
|
||||
existingFeatures: rawSession.project_context?.existing_features || rawSession.projectContext?.existingFeatures || [],
|
||||
techStack: rawSession.project_context?.tech_stack || rawSession.projectContext?.techStack || [],
|
||||
targetAudience: rawSession.project_context?.target_audience || rawSession.projectContext?.targetAudience,
|
||||
plannedFeatures: rawSession.project_context?.planned_features || rawSession.projectContext?.plannedFeatures || []
|
||||
},
|
||||
generatedAt: rawSession.generated_at ? new Date(rawSession.generated_at) : new Date(),
|
||||
updatedAt: rawSession.updated_at ? new Date(rawSession.updated_at) : new Date()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapFeature,
|
||||
RoadmapPhase,
|
||||
RoadmapMilestone
|
||||
} from '../../../shared/types';
|
||||
|
||||
interface RawRoadmapMilestone {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features?: string[];
|
||||
status?: string;
|
||||
target_date?: string;
|
||||
}
|
||||
|
||||
interface RawRoadmapPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
order: number;
|
||||
status?: string;
|
||||
features?: string[];
|
||||
milestones?: RawRoadmapMilestone[];
|
||||
}
|
||||
|
||||
interface RawRoadmapFeature {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
rationale?: string;
|
||||
priority?: string;
|
||||
complexity?: string;
|
||||
impact?: string;
|
||||
phase_id?: string;
|
||||
phaseId?: string;
|
||||
dependencies?: string[];
|
||||
status?: string;
|
||||
acceptance_criteria?: string[];
|
||||
acceptanceCriteria?: string[];
|
||||
user_stories?: string[];
|
||||
userStories?: string[];
|
||||
linked_spec_id?: string;
|
||||
linkedSpecId?: string;
|
||||
competitor_insight_ids?: string[];
|
||||
competitorInsightIds?: string[];
|
||||
}
|
||||
|
||||
interface RawRoadmap {
|
||||
id?: string;
|
||||
project_name?: string;
|
||||
projectName?: string;
|
||||
version?: string;
|
||||
vision?: string;
|
||||
target_audience?: {
|
||||
primary?: string;
|
||||
secondary?: string[];
|
||||
};
|
||||
targetAudience?: {
|
||||
primary?: string;
|
||||
secondary?: string[];
|
||||
};
|
||||
phases?: RawRoadmapPhase[];
|
||||
features?: RawRoadmapFeature[];
|
||||
status?: string;
|
||||
metadata?: {
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
updated_at?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function transformMilestone(raw: RawRoadmapMilestone): RoadmapMilestone {
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: raw.description,
|
||||
features: raw.features || [],
|
||||
status: (raw.status as 'planned' | 'achieved') || 'planned',
|
||||
targetDate: raw.target_date ? new Date(raw.target_date) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPhase(raw: RawRoadmapPhase): RoadmapPhase {
|
||||
return {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
order: raw.order,
|
||||
status: (raw.status as RoadmapPhase['status']) || 'planned',
|
||||
features: raw.features || [],
|
||||
milestones: (raw.milestones || []).map(transformMilestone)
|
||||
};
|
||||
}
|
||||
|
||||
function transformFeature(raw: RawRoadmapFeature): RoadmapFeature {
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.title,
|
||||
description: raw.description,
|
||||
rationale: raw.rationale || '',
|
||||
priority: (raw.priority as RoadmapFeature['priority']) || 'should',
|
||||
complexity: (raw.complexity as RoadmapFeature['complexity']) || 'medium',
|
||||
impact: (raw.impact as RoadmapFeature['impact']) || 'medium',
|
||||
phaseId: raw.phase_id || raw.phaseId || '',
|
||||
dependencies: raw.dependencies || [],
|
||||
status: (raw.status as RoadmapFeature['status']) || 'under_review',
|
||||
acceptanceCriteria: raw.acceptance_criteria || raw.acceptanceCriteria || [],
|
||||
userStories: raw.user_stories || raw.userStories || [],
|
||||
linkedSpecId: raw.linked_spec_id || raw.linkedSpecId,
|
||||
competitorInsightIds: raw.competitor_insight_ids || raw.competitorInsightIds
|
||||
};
|
||||
}
|
||||
|
||||
export function transformRoadmapFromSnakeCase(
|
||||
raw: RawRoadmap,
|
||||
projectId: string,
|
||||
projectName?: string
|
||||
): Roadmap {
|
||||
const targetAudience = raw.target_audience || raw.targetAudience;
|
||||
const createdAt = raw.metadata?.created_at || raw.created_at || raw.createdAt;
|
||||
const updatedAt = raw.metadata?.updated_at || raw.updated_at || raw.updatedAt;
|
||||
|
||||
return {
|
||||
id: raw.id || `roadmap-${Date.now()}`,
|
||||
projectId,
|
||||
projectName: raw.project_name || raw.projectName || projectName || '',
|
||||
version: raw.version || '1.0',
|
||||
vision: raw.vision || '',
|
||||
targetAudience: {
|
||||
primary: targetAudience?.primary || '',
|
||||
secondary: targetAudience?.secondary || []
|
||||
},
|
||||
phases: (raw.phases || []).map(transformPhase),
|
||||
features: (raw.features || []).map(transformFeature),
|
||||
status: (raw.status as Roadmap['status']) || 'draft',
|
||||
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
||||
updatedAt: updatedAt ? new Date(updatedAt) : new Date()
|
||||
};
|
||||
}
|
||||
@@ -76,10 +76,11 @@ export function GenerationProgressScreen({
|
||||
}
|
||||
}, [logs, showLogs]);
|
||||
|
||||
// Get ideas for a specific type from the current session
|
||||
const getStreamingIdeasByType = (type: IdeationType): Idea[] => {
|
||||
if (!session) return [];
|
||||
return session.ideas.filter((idea) => idea.type === type);
|
||||
return session.ideas.filter(
|
||||
(idea) => idea.type === type && idea.status !== 'dismissed' && idea.status !== 'archived'
|
||||
);
|
||||
};
|
||||
|
||||
// Count how many types are still generating
|
||||
|
||||
@@ -20,6 +20,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
|
||||
const {
|
||||
session,
|
||||
generationStatus,
|
||||
isGenerating,
|
||||
config,
|
||||
logs,
|
||||
typeStates,
|
||||
@@ -64,8 +65,8 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
|
||||
getIdeasByType
|
||||
} = useIdeation(projectId, { onGoToTask });
|
||||
|
||||
// Show generation progress with streaming ideas
|
||||
if (generationStatus.phase !== 'idle' && generationStatus.phase !== 'complete' && generationStatus.phase !== 'error') {
|
||||
// Show generation progress with streaming ideas (use isGenerating flag for reliable state)
|
||||
if (isGenerating) {
|
||||
return (
|
||||
<GenerationProgressScreen
|
||||
generationStatus={generationStatus}
|
||||
@@ -83,7 +84,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state
|
||||
// Show empty state only when no session exists (first run)
|
||||
if (!session) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
|
||||
const { onGoToTask } = options;
|
||||
const session = useIdeationStore((state) => state.session);
|
||||
const generationStatus = useIdeationStore((state) => state.generationStatus);
|
||||
const isGenerating = useIdeationStore((state) => state.isGenerating);
|
||||
const config = useIdeationStore((state) => state.config);
|
||||
const setConfig = useIdeationStore((state) => state.setConfig);
|
||||
const logs = useIdeationStore((state) => state.logs);
|
||||
@@ -196,6 +197,7 @@ export function useIdeation(projectId: string, options: UseIdeationOptions = {})
|
||||
// State
|
||||
session,
|
||||
generationStatus,
|
||||
isGenerating,
|
||||
config,
|
||||
logs,
|
||||
typeStates,
|
||||
|
||||
@@ -10,7 +10,18 @@ import type {
|
||||
} from '../../shared/types';
|
||||
import { DEFAULT_IDEATION_CONFIG } from '../../shared/constants';
|
||||
|
||||
// Tracks the state of each ideation type during parallel generation
|
||||
const GENERATION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
const generationTimeoutIds = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function clearGenerationTimeout(projectId: string): void {
|
||||
const timeoutId = generationTimeoutIds.get(projectId);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
generationTimeoutIds.delete(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
export type IdeationTypeState = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
|
||||
interface IdeationState {
|
||||
@@ -19,13 +30,13 @@ interface IdeationState {
|
||||
generationStatus: IdeationGenerationStatus;
|
||||
config: IdeationConfig;
|
||||
logs: string[];
|
||||
// Track which ideation types are pending, generating, completed, or failed
|
||||
typeStates: Record<IdeationType, IdeationTypeState>;
|
||||
// Selection state
|
||||
selectedIds: Set<string>;
|
||||
isGenerating: boolean;
|
||||
|
||||
// Actions
|
||||
setSession: (session: IdeationSession | null) => void;
|
||||
setIsGenerating: (isGenerating: boolean) => void;
|
||||
setGenerationStatus: (status: IdeationGenerationStatus) => void;
|
||||
setConfig: (config: Partial<IdeationConfig>) => void;
|
||||
updateIdeaStatus: (ideaId: string, status: IdeationStatus) => void;
|
||||
@@ -46,6 +57,7 @@ interface IdeationState {
|
||||
initializeTypeStates: (types: IdeationType[]) => void;
|
||||
setTypeState: (type: IdeationType, state: IdeationTypeState) => void;
|
||||
addIdeasForType: (ideationType: string, ideas: Idea[]) => void;
|
||||
resetGeneratingTypes: (toState: IdeationTypeState) => void;
|
||||
}
|
||||
|
||||
const initialGenerationStatus: IdeationGenerationStatus = {
|
||||
@@ -80,10 +92,13 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
logs: [],
|
||||
typeStates: { ...initialTypeStates },
|
||||
selectedIds: new Set<string>(),
|
||||
isGenerating: false,
|
||||
|
||||
// Actions
|
||||
setSession: (session) => set({ session }),
|
||||
|
||||
setIsGenerating: (isGenerating) => set({ isGenerating }),
|
||||
|
||||
setGenerationStatus: (status) => set({ generationStatus: status }),
|
||||
|
||||
setConfig: (newConfig) =>
|
||||
@@ -281,21 +296,18 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
typeStates: { ...prevState.typeStates, [type]: state }
|
||||
})),
|
||||
|
||||
// Add ideas for a specific type (streaming update)
|
||||
addIdeasForType: (ideationType, ideas) =>
|
||||
set((state) => {
|
||||
// Update type state to completed
|
||||
const newTypeStates = { ...state.typeStates };
|
||||
newTypeStates[ideationType as IdeationType] = 'completed';
|
||||
|
||||
// If no session exists yet, create a partial one
|
||||
if (!state.session) {
|
||||
const config = state.config;
|
||||
return {
|
||||
typeStates: newTypeStates,
|
||||
session: {
|
||||
id: `session-${Date.now()}`,
|
||||
projectId: '', // Will be set by final session
|
||||
projectId: '',
|
||||
config,
|
||||
ideas,
|
||||
projectContext: {
|
||||
@@ -309,24 +321,45 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
};
|
||||
}
|
||||
|
||||
// Merge new ideas with existing ones (avoid duplicates by id)
|
||||
const existingIds = new Set(state.session.ideas.map((i) => i.id));
|
||||
const newIdeas = ideas.filter((idea) => !existingIds.has(idea.id));
|
||||
// Replace ideas of this type (remove old ones including dismissed), keep other types
|
||||
const otherTypeIdeas = state.session.ideas.filter(
|
||||
(idea) => idea.type !== ideationType
|
||||
);
|
||||
|
||||
return {
|
||||
typeStates: newTypeStates,
|
||||
session: {
|
||||
...state.session,
|
||||
ideas: [...state.session.ideas, ...newIdeas],
|
||||
ideas: [...otherTypeIdeas, ...ideas],
|
||||
updatedAt: new Date()
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
resetGeneratingTypes: (toState: IdeationTypeState) =>
|
||||
set((state) => {
|
||||
const newTypeStates = { ...state.typeStates };
|
||||
Object.entries(newTypeStates).forEach(([type, currentState]) => {
|
||||
if (currentState === 'generating') {
|
||||
newTypeStates[type as IdeationType] = toState;
|
||||
}
|
||||
});
|
||||
return { typeStates: newTypeStates };
|
||||
})
|
||||
}));
|
||||
|
||||
// Helper functions for loading ideation
|
||||
export async function loadIdeation(projectId: string): Promise<void> {
|
||||
if (useIdeationStore.getState().isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getIdeation(projectId);
|
||||
|
||||
// Check again after async operation to handle race condition
|
||||
if (useIdeationStore.getState().isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
useIdeationStore.getState().setSession(result.data);
|
||||
} else {
|
||||
@@ -338,7 +371,6 @@ export function generateIdeation(projectId: string): void {
|
||||
const store = useIdeationStore.getState();
|
||||
const config = store.config;
|
||||
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Starting generation:', {
|
||||
projectId,
|
||||
@@ -349,8 +381,11 @@ export function generateIdeation(projectId: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
clearGenerationTimeout(projectId);
|
||||
|
||||
store.clearLogs();
|
||||
store.clearSession(); // Clear existing session for fresh generation
|
||||
store.clearSession();
|
||||
store.setIsGenerating(true);
|
||||
store.initializeTypeStates(config.enabledTypes);
|
||||
store.addLog('Starting ideation generation in parallel...');
|
||||
store.setGenerationStatus({
|
||||
@@ -358,6 +393,27 @@ export function generateIdeation(projectId: string): void {
|
||||
progress: 0,
|
||||
message: `Generating ${config.enabledTypes.length} ideation types in parallel...`
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const currentState = useIdeationStore.getState();
|
||||
if (currentState.generationStatus.phase === 'generating') {
|
||||
if (window.DEBUG) {
|
||||
console.warn('[Ideation] Generation timed out after', GENERATION_TIMEOUT_MS, 'ms');
|
||||
}
|
||||
clearGenerationTimeout(projectId);
|
||||
currentState.setIsGenerating(false);
|
||||
currentState.resetGeneratingTypes('failed');
|
||||
currentState.setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
message: '',
|
||||
error: 'Generation timed out. Some ideas may have been generated - check the results.'
|
||||
});
|
||||
currentState.addLog('⚠ Generation timed out');
|
||||
}
|
||||
}, GENERATION_TIMEOUT_MS);
|
||||
generationTimeoutIds.set(projectId, timeoutId);
|
||||
|
||||
window.electronAPI.generateIdeation(projectId, config);
|
||||
}
|
||||
|
||||
@@ -369,8 +425,7 @@ export async function stopIdeation(projectId: string): Promise<boolean> {
|
||||
console.log('[Ideation] Stop requested:', { projectId });
|
||||
}
|
||||
|
||||
// Always update UI state to 'idle' when user requests stop, regardless of backend response
|
||||
// This prevents the UI from getting stuck in "generating" state if the process already ended
|
||||
store.setIsGenerating(false);
|
||||
store.addLog('Stopping ideation generation...');
|
||||
store.setGenerationStatus({
|
||||
phase: 'idle',
|
||||
@@ -399,11 +454,11 @@ export async function refreshIdeation(projectId: string): Promise<void> {
|
||||
const store = useIdeationStore.getState();
|
||||
const config = store.config;
|
||||
|
||||
// Stop any existing generation first
|
||||
await window.electronAPI.stopIdeation(projectId);
|
||||
|
||||
store.clearLogs();
|
||||
store.clearSession(); // Clear existing session for fresh generation
|
||||
store.clearSession();
|
||||
store.setIsGenerating(true);
|
||||
store.initializeTypeStates(config.enabledTypes);
|
||||
store.addLog('Refreshing ideation in parallel...');
|
||||
store.setGenerationStatus({
|
||||
@@ -464,11 +519,9 @@ export function appendIdeation(projectId: string, typesToAdd: IdeationType[]): v
|
||||
const store = useIdeationStore.getState();
|
||||
const config = store.config;
|
||||
|
||||
// Don't clear existing session - we're appending
|
||||
store.clearLogs();
|
||||
store.setIsGenerating(true);
|
||||
|
||||
// Only initialize states for the new types we're adding
|
||||
// Keep existing type states as 'completed' for types we already have
|
||||
const newTypeStates = { ...store.typeStates };
|
||||
typesToAdd.forEach((type) => {
|
||||
newTypeStates[type] = 'generating';
|
||||
@@ -482,7 +535,6 @@ export function appendIdeation(projectId: string, typesToAdd: IdeationType[]): v
|
||||
message: `Generating ${typesToAdd.length} additional ideation types...`
|
||||
});
|
||||
|
||||
// Call generate with append mode and only the new types
|
||||
const appendConfig = {
|
||||
...config,
|
||||
enabledTypes: typesToAdd,
|
||||
@@ -527,16 +579,20 @@ export function getIdeationSummary(session: IdeationSession | null): IdeationSum
|
||||
};
|
||||
}
|
||||
|
||||
const activeIdeas = session.ideas.filter(
|
||||
(idea) => idea.status !== 'dismissed' && idea.status !== 'archived'
|
||||
);
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
const byStatus: Record<string, number> = {};
|
||||
|
||||
session.ideas.forEach((idea) => {
|
||||
activeIdeas.forEach((idea) => {
|
||||
byType[idea.type] = (byType[idea.type] || 0) + 1;
|
||||
byStatus[idea.status] = (byStatus[idea.status] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
totalIdeas: session.ideas.length,
|
||||
totalIdeas: activeIdeas.length,
|
||||
byType: byType as Record<IdeationType, number>,
|
||||
byStatus: byStatus as Record<IdeationStatus, number>,
|
||||
lastGenerated: session.generatedAt
|
||||
@@ -625,9 +681,7 @@ export function setupIdeationListeners(): () => void {
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for completion (final session with all data)
|
||||
const unsubComplete = window.electronAPI.onIdeationComplete((_projectId, session) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Generation complete:', {
|
||||
projectId: _projectId,
|
||||
@@ -639,8 +693,11 @@ export function setupIdeationListeners(): () => void {
|
||||
});
|
||||
}
|
||||
|
||||
// Final session replaces the partial one with complete data
|
||||
clearGenerationTimeout(_projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().setSession(session);
|
||||
store().resetGeneratingTypes('completed');
|
||||
store().setGenerationStatus({
|
||||
phase: 'complete',
|
||||
progress: 100,
|
||||
@@ -649,13 +706,15 @@ export function setupIdeationListeners(): () => void {
|
||||
store().addLog('Ideation generation complete!');
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
const unsubError = window.electronAPI.onIdeationError((_projectId, error) => {
|
||||
// Debug logging
|
||||
if (window.DEBUG) {
|
||||
console.error('[Ideation] Error received:', { projectId: _projectId, error });
|
||||
}
|
||||
|
||||
clearGenerationTimeout(_projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().resetGeneratingTypes('failed');
|
||||
store().setGenerationStatus({
|
||||
phase: 'error',
|
||||
progress: 0,
|
||||
@@ -665,8 +724,15 @@ export function setupIdeationListeners(): () => void {
|
||||
store().addLog(`Error: ${error}`);
|
||||
});
|
||||
|
||||
// Listen for stopped event
|
||||
const unsubStopped = window.electronAPI.onIdeationStopped((_projectId) => {
|
||||
if (window.DEBUG) {
|
||||
console.log('[Ideation] Stopped:', { projectId: _projectId });
|
||||
}
|
||||
|
||||
clearGenerationTimeout(_projectId);
|
||||
|
||||
store().setIsGenerating(false);
|
||||
store().resetGeneratingTypes('pending');
|
||||
store().setGenerationStatus({
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
@@ -675,8 +741,11 @@ export function setupIdeationListeners(): () => void {
|
||||
store().addLog('Ideation generation stopped');
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
for (const [projectId] of generationTimeoutIds) {
|
||||
clearGenerationTimeout(projectId);
|
||||
}
|
||||
|
||||
unsubProgress();
|
||||
unsubLog();
|
||||
unsubTypeComplete();
|
||||
|
||||
@@ -18,7 +18,7 @@ export type IdeationType =
|
||||
| 'performance_optimizations'
|
||||
| 'code_quality';
|
||||
export type IdeationStatus = 'draft' | 'selected' | 'converted' | 'dismissed' | 'archived';
|
||||
export type IdeationGenerationPhase = 'idle' | 'analyzing' | 'discovering' | 'generating' | 'complete' | 'error';
|
||||
export type IdeationGenerationPhase = 'idle' | 'analyzing' | 'discovering' | 'generating' | 'finalizing' | 'complete' | 'error';
|
||||
|
||||
export interface IdeationConfig {
|
||||
enabledTypes: IdeationType[];
|
||||
|
||||
Reference in New Issue
Block a user