diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index 19ad9ea3..4126e7e7 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -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 => { + try { + const content = await fsPromises.readFile(typeFilePath, 'utf-8'); + const data: Record = 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 => { + 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 => { + 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 }); diff --git a/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts b/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts index 984ff53e..a5097f30 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation-handlers.ts @@ -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); + }; } diff --git a/apps/frontend/src/main/ipc-handlers/ideation/transformers.ts b/apps/frontend/src/main/ipc-handlers/ideation/transformers.ts index 758a2498..60cd1105 100644 --- a/apps/frontend/src/main/ipc-handlers/ideation/transformers.ts +++ b/apps/frontend/src/main/ipc-handlers/ideation/transformers.ts @@ -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 = 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() + }; +} diff --git a/apps/frontend/src/main/ipc-handlers/roadmap/transformers.ts b/apps/frontend/src/main/ipc-handlers/roadmap/transformers.ts new file mode 100644 index 00000000..0eb8b3aa --- /dev/null +++ b/apps/frontend/src/main/ipc-handlers/roadmap/transformers.ts @@ -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() + }; +} diff --git a/apps/frontend/src/renderer/components/ideation/GenerationProgressScreen.tsx b/apps/frontend/src/renderer/components/ideation/GenerationProgressScreen.tsx index 821a9eae..aaba160a 100644 --- a/apps/frontend/src/renderer/components/ideation/GenerationProgressScreen.tsx +++ b/apps/frontend/src/renderer/components/ideation/GenerationProgressScreen.tsx @@ -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 diff --git a/apps/frontend/src/renderer/components/ideation/Ideation.tsx b/apps/frontend/src/renderer/components/ideation/Ideation.tsx index 72ac1061..3d6bbd0c 100644 --- a/apps/frontend/src/renderer/components/ideation/Ideation.tsx +++ b/apps/frontend/src/renderer/components/ideation/Ideation.tsx @@ -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 ( diff --git a/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts b/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts index 425cb4aa..5be67f4c 100644 --- a/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts +++ b/apps/frontend/src/renderer/components/ideation/hooks/useIdeation.ts @@ -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, diff --git a/apps/frontend/src/renderer/stores/ideation-store.ts b/apps/frontend/src/renderer/stores/ideation-store.ts index 44923b78..81b07dce 100644 --- a/apps/frontend/src/renderer/stores/ideation-store.ts +++ b/apps/frontend/src/renderer/stores/ideation-store.ts @@ -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>(); + +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; - // Selection state selectedIds: Set; + isGenerating: boolean; // Actions setSession: (session: IdeationSession | null) => void; + setIsGenerating: (isGenerating: boolean) => void; setGenerationStatus: (status: IdeationGenerationStatus) => void; setConfig: (config: Partial) => 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((set) => ({ logs: [], typeStates: { ...initialTypeStates }, selectedIds: new Set(), + 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((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((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 { + 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 { 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 { 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 = {}; const byStatus: Record = {}; - 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, byStatus: byStatus as Record, 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(); diff --git a/apps/frontend/src/shared/types/insights.ts b/apps/frontend/src/shared/types/insights.ts index c1145ac8..5d77e13f 100644 --- a/apps/frontend/src/shared/types/insights.ts +++ b/apps/frontend/src/shared/types/insights.ts @@ -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[];