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:
souky-byte
2025-12-23 18:02:45 +01:00
committed by GitHub
parent 53527293cd
commit 6ec8549f63
9 changed files with 514 additions and 61 deletions
+94 -22
View File
@@ -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();
+1 -1
View File
@@ -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[];