Files
automaker/apps/ui/src/store/ideation-store.ts
2026-01-24 13:13:11 +01:00

386 lines
12 KiB
TypeScript

/**
* Ideation Store - State management for brainstorming and idea management
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
Idea,
IdeaCategory,
IdeaStatus,
IdeationPrompt,
AnalysisSuggestion,
ProjectAnalysisResult,
IdeationContextSources,
} from '@automaker/types';
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
// ============================================================================
// Generation Job Types
// ============================================================================
export type GenerationJobStatus = 'generating' | 'ready' | 'error';
export interface GenerationJob {
id: string;
projectPath: string;
prompt: IdeationPrompt;
status: GenerationJobStatus;
suggestions: AnalysisSuggestion[];
error: string | null;
startedAt: string;
completedAt: string | null;
}
// ============================================================================
// State Interface
// ============================================================================
export type IdeationMode = 'dashboard' | 'prompts';
interface IdeationState {
// Ideas (saved for later)
ideas: Idea[];
selectedIdeaId: string | null;
// Generation jobs (multiple concurrent generations)
generationJobs: GenerationJob[];
selectedJobId: string | null;
// Legacy - keep for backwards compat during transition
suggestions: AnalysisSuggestion[];
selectedPrompt: IdeationPrompt | null;
isGenerating: boolean;
generatingError: string | null;
// Analysis
analysisResult: ProjectAnalysisResult | null;
isAnalyzing: boolean;
analysisProgress: number;
analysisMessage: string;
// UI state
currentMode: IdeationMode;
selectedCategory: IdeaCategory | null;
filterStatus: IdeaStatus | 'all';
// Context sources per project
contextSourcesByProject: Record<string, Partial<IdeationContextSources>>;
}
// ============================================================================
// Actions Interface
// ============================================================================
interface IdeationActions {
// Ideas
setIdeas: (ideas: Idea[]) => void;
addIdea: (idea: Idea) => void;
updateIdea: (id: string, updates: Partial<Idea>) => void;
removeIdea: (id: string) => void;
setSelectedIdea: (id: string | null) => void;
getSelectedIdea: () => Idea | null;
// Generation Jobs
addGenerationJob: (projectPath: string, prompt: IdeationPrompt) => string;
updateJobStatus: (
jobId: string,
status: GenerationJobStatus,
suggestions?: AnalysisSuggestion[],
error?: string
) => void;
removeJob: (jobId: string) => void;
clearCompletedJobs: () => void;
setSelectedJob: (jobId: string | null) => void;
getJob: (jobId: string) => GenerationJob | null;
removeSuggestionFromJob: (jobId: string, suggestionId: string) => void;
appendSuggestionsToJob: (jobId: string, suggestions: AnalysisSuggestion[]) => void;
setJobGenerating: (jobId: string, generating: boolean) => void;
// Legacy Suggestions (kept for backwards compat)
setSuggestions: (suggestions: AnalysisSuggestion[]) => void;
clearSuggestions: () => void;
removeSuggestion: (id: string) => void;
setSelectedPrompt: (prompt: IdeationPrompt | null) => void;
setIsGenerating: (isGenerating: boolean) => void;
setGeneratingError: (error: string | null) => void;
// Analysis
setAnalysisResult: (result: ProjectAnalysisResult | null) => void;
setIsAnalyzing: (isAnalyzing: boolean) => void;
setAnalysisProgress: (progress: number, message?: string) => void;
// UI
setMode: (mode: IdeationMode) => void;
setCategory: (category: IdeaCategory | null) => void;
setFilterStatus: (status: IdeaStatus | 'all') => void;
// Context sources
/**
* Returns the effective context-source settings for a project,
* merging defaults with any stored overrides.
*/
getContextSources: (projectPath: string) => IdeationContextSources;
/**
* Updates a single context-source flag for a project.
*/
setContextSource: (
projectPath: string,
key: keyof IdeationContextSources,
value: boolean
) => void;
// Reset
reset: () => void;
resetSuggestions: () => void;
}
// ============================================================================
// Initial State
// ============================================================================
const initialState: IdeationState = {
ideas: [],
selectedIdeaId: null,
generationJobs: [],
selectedJobId: null,
suggestions: [],
selectedPrompt: null,
isGenerating: false,
generatingError: null,
analysisResult: null,
isAnalyzing: false,
analysisProgress: 0,
analysisMessage: '',
currentMode: 'dashboard',
selectedCategory: null,
filterStatus: 'all',
contextSourcesByProject: {},
};
// ============================================================================
// Store
// ============================================================================
export const useIdeationStore = create<IdeationState & IdeationActions>()(
persist(
(set, get) => ({
...initialState,
// Ideas
setIdeas: (ideas) => set({ ideas }),
addIdea: (idea) =>
set((state) => ({
ideas: [idea, ...state.ideas],
})),
updateIdea: (id, updates) =>
set((state) => ({
ideas: state.ideas.map((idea) => (idea.id === id ? { ...idea, ...updates } : idea)),
})),
removeIdea: (id) =>
set((state) => ({
ideas: state.ideas.filter((idea) => idea.id !== id),
selectedIdeaId: state.selectedIdeaId === id ? null : state.selectedIdeaId,
})),
setSelectedIdea: (id) => set({ selectedIdeaId: id }),
getSelectedIdea: () => {
const state = get();
return state.ideas.find((idea) => idea.id === state.selectedIdeaId) || null;
},
// Generation Jobs
addGenerationJob: (projectPath, prompt) => {
const jobId = `job-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const job: GenerationJob = {
id: jobId,
projectPath,
prompt,
status: 'generating',
suggestions: [],
error: null,
startedAt: new Date().toISOString(),
completedAt: null,
};
set((state) => ({
generationJobs: [job, ...state.generationJobs],
}));
return jobId;
},
updateJobStatus: (jobId, status, suggestions, error) =>
set((state) => ({
generationJobs: state.generationJobs.map((job) =>
job.id === jobId
? {
...job,
status,
suggestions: suggestions || job.suggestions,
error: error || null,
completedAt: status !== 'generating' ? new Date().toISOString() : null,
}
: job
),
})),
removeJob: (jobId) =>
set((state) => ({
generationJobs: state.generationJobs.filter((job) => job.id !== jobId),
selectedJobId: state.selectedJobId === jobId ? null : state.selectedJobId,
})),
clearCompletedJobs: () =>
set((state) => ({
generationJobs: state.generationJobs.filter((job) => job.status === 'generating'),
})),
setSelectedJob: (jobId) => set({ selectedJobId: jobId }),
getJob: (jobId) => {
const state = get();
return state.generationJobs.find((job) => job.id === jobId) || null;
},
removeSuggestionFromJob: (jobId, suggestionId) =>
set((state) => ({
generationJobs: state.generationJobs.map((job) =>
job.id === jobId
? {
...job,
suggestions: job.suggestions.filter((s) => s.id !== suggestionId),
}
: job
),
})),
appendSuggestionsToJob: (jobId, suggestions) =>
set((state) => ({
generationJobs: state.generationJobs.map((job) =>
job.id === jobId
? {
...job,
suggestions: [...job.suggestions, ...suggestions],
status: 'ready' as const,
}
: job
),
})),
setJobGenerating: (jobId, generating) =>
set((state) => ({
generationJobs: state.generationJobs.map((job) =>
job.id === jobId
? {
...job,
status: generating ? ('generating' as const) : ('ready' as const),
}
: job
),
})),
// Suggestions (legacy)
setSuggestions: (suggestions) => set({ suggestions }),
clearSuggestions: () => set({ suggestions: [], generatingError: null }),
removeSuggestion: (id) =>
set((state) => ({
suggestions: state.suggestions.filter((s) => s.id !== id),
})),
setSelectedPrompt: (prompt) => set({ selectedPrompt: prompt }),
setIsGenerating: (isGenerating) => set({ isGenerating }),
setGeneratingError: (error) => set({ generatingError: error }),
// Analysis
setAnalysisResult: (result) => set({ analysisResult: result }),
setIsAnalyzing: (isAnalyzing) =>
set({
isAnalyzing,
analysisProgress: isAnalyzing ? 0 : get().analysisProgress,
analysisMessage: isAnalyzing ? 'Starting analysis...' : '',
}),
setAnalysisProgress: (progress, message) =>
set({
analysisProgress: progress,
analysisMessage: message || get().analysisMessage,
}),
// UI
setMode: (mode) => set({ currentMode: mode }),
setCategory: (category) => set({ selectedCategory: category }),
setFilterStatus: (status) => set({ filterStatus: status }),
// Context sources
getContextSources: (projectPath) => {
const state = get();
const projectOverrides = state.contextSourcesByProject[projectPath] ?? {};
return { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides };
},
setContextSource: (projectPath, key, value) =>
set((state) => ({
contextSourcesByProject: {
...state.contextSourcesByProject,
[projectPath]: {
...state.contextSourcesByProject[projectPath],
[key]: value,
},
},
})),
// Reset
reset: () => set(initialState),
resetSuggestions: () =>
set({
suggestions: [],
selectedPrompt: null,
isGenerating: false,
generatingError: null,
}),
}),
{
name: 'automaker-ideation-store',
version: 5,
partialize: (state) => ({
// Only persist these fields
ideas: state.ideas,
generationJobs: state.generationJobs,
analysisResult: state.analysisResult,
filterStatus: state.filterStatus,
contextSourcesByProject: state.contextSourcesByProject,
}),
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Record<string, unknown>;
if (version < 4) {
// Remove legacy jobs that don't have projectPath (from before project-scoping was added)
const jobs = (state.generationJobs as GenerationJob[]) || [];
return {
...state,
generationJobs: jobs.filter((job) => job.projectPath !== undefined),
};
}
if (version < 5) {
// Initialize contextSourcesByProject if not present
return {
...state,
contextSourcesByProject: state.contextSourcesByProject ?? {},
};
}
return state;
},
}
)
);