diff --git a/Dockerfile b/Dockerfile index dcf80b64..03911b45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ COPY libs/types/package*.json ./libs/types/ COPY libs/utils/package*.json ./libs/utils/ COPY libs/prompts/package*.json ./libs/prompts/ COPY libs/platform/package*.json ./libs/platform/ +COPY libs/spec-parser/package*.json ./libs/spec-parser/ COPY libs/model-resolver/package*.json ./libs/model-resolver/ COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/ COPY libs/git-utils/package*.json ./libs/git-utils/ diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts index 8add2af5..ffb4e8ac 100644 --- a/apps/server/src/routes/ideation/routes/suggestions-generate.ts +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -4,15 +4,21 @@ import type { Request, Response } from 'express'; import type { IdeationService } from '../../../services/ideation-service.js'; +import type { IdeationContextSources } from '@automaker/types'; import { createLogger } from '@automaker/utils'; import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('ideation:suggestions-generate'); +/** + * Creates an Express route handler for generating AI-powered ideation suggestions. + * Accepts a prompt, category, and optional context sources configuration, + * then returns structured suggestions that can be added to the board. + */ export function createSuggestionsGenerateHandler(ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, promptId, category, count } = req.body; + const { projectPath, promptId, category, count, contextSources } = req.body; if (!projectPath) { res.status(400).json({ success: false, error: 'projectPath is required' }); @@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic projectPath, promptId, category, - suggestionCount + suggestionCount, + contextSources as IdeationContextSources | undefined ); res.json({ diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index f0d9c030..0f8021f1 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry { checkedAt: number; } +interface GitHubPRCacheEntry { + prs: Map; + fetchedAt: number; +} + const githubRemoteCache = new Map(); +const githubPRCache = new Map(); const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll interface WorktreeInfo { path: string; @@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise> { +async function fetchGitHubPRs( + projectPath: string, + forceRefresh = false +): Promise> { + const now = Date.now(); + const cached = githubPRCache.get(projectPath); + + // Return cached result if valid and not forcing refresh + if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) { + return cached.prs; + } + const prMap = new Map(); try { @@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise(); for (const worktree of worktrees) { diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 990a4552..62edeaae 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -23,7 +23,9 @@ import type { SendMessageOptions, PromptCategory, IdeationPrompt, + IdeationContextSources, } from '@automaker/types'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; import { getIdeationDir, getIdeasDir, @@ -32,8 +34,10 @@ import { getIdeationSessionsDir, getIdeationSessionPath, getIdeationAnalysisPath, + getAppSpecPath, ensureIdeationDir, } from '@automaker/platform'; +import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js'; import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import type { SettingsService } from './settings-service.js'; @@ -638,8 +642,12 @@ export class IdeationService { projectPath: string, promptId: string, category: IdeaCategory, - count: number = 10 + count: number = 10, + contextSources?: IdeationContextSources ): Promise { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); + // Merge with defaults for backward compatibility + const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources }; validateWorkingDirectory(projectPath); // Get the prompt @@ -656,16 +664,26 @@ export class IdeationService { }); try { - // Load context files + // Load context files (respecting toggle settings) const contextResult = await loadContextFiles({ projectPath, fsModule: secureFs as Parameters[0]['fsModule'], + includeContextFiles: sources.useContextFiles, + includeMemory: sources.useMemoryFiles, }); // Build context from multiple sources let contextPrompt = contextResult.formattedPrompt; - // If no context files, try to gather basic project info + // Add app spec context if enabled + if (sources.useAppSpec) { + const appSpecContext = await this.buildAppSpecContext(projectPath); + if (appSpecContext) { + contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext; + } + } + + // If no context was found, try to gather basic project info if (!contextPrompt) { const projectInfo = await this.gatherBasicProjectInfo(projectPath); if (projectInfo) { @@ -673,8 +691,11 @@ export class IdeationService { } } - // Gather existing features and ideas to prevent duplicates - const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + // Gather existing features and ideas to prevent duplicates (respecting toggle settings) + const existingWorkContext = await this.gatherExistingWorkContext(projectPath, { + includeFeatures: sources.useExistingFeatures, + includeIdeas: sources.useExistingIdeas, + }); // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); @@ -684,7 +705,7 @@ export class IdeationService { prompts.ideation.suggestionsSystemPrompt, contextPrompt, category, - count, + suggestionCount, existingWorkContext ); @@ -751,7 +772,11 @@ export class IdeationService { } // Parse the response into structured suggestions - const suggestions = this.parseSuggestionsFromResponse(responseText, category); + const suggestions = this.parseSuggestionsFromResponse( + responseText, + category, + suggestionCount + ); // Emit complete event this.events.emit('ideation:suggestions', { @@ -814,40 +839,47 @@ ${contextSection}${existingWorkSection}`; */ private parseSuggestionsFromResponse( response: string, - category: IdeaCategory + category: IdeaCategory, + count: number ): AnalysisSuggestion[] { try { // Try to extract JSON from the response const jsonMatch = response.match(/\[[\s\S]*\]/); if (!jsonMatch) { logger.warn('No JSON array found in response, falling back to text parsing'); - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, count); } const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, count); } - return parsed.map((item: any, index: number) => ({ - id: this.generateId('sug'), - category, - title: item.title || `Suggestion ${index + 1}`, - description: item.description || '', - rationale: item.rationale || '', - priority: item.priority || 'medium', - relatedFiles: item.relatedFiles || [], - })); + return parsed + .map((item: any, index: number) => ({ + id: this.generateId('sug'), + category, + title: item.title || `Suggestion ${index + 1}`, + description: item.description || '', + rationale: item.rationale || '', + priority: item.priority || 'medium', + relatedFiles: item.relatedFiles || [], + })) + .slice(0, count); } catch (error) { logger.warn('Failed to parse JSON response:', error); - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, count); } } /** * Fallback: parse text response into suggestions */ - private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] { + private parseTextResponse( + response: string, + category: IdeaCategory, + count: number + ): AnalysisSuggestion[] { const suggestions: AnalysisSuggestion[] = []; // Try to find numbered items or headers @@ -907,7 +939,7 @@ ${contextSection}${existingWorkSection}`; }); } - return suggestions.slice(0, 5); // Max 5 suggestions + return suggestions.slice(0, count); } // ============================================================================ @@ -1345,6 +1377,68 @@ ${contextSection}${existingWorkSection}`; return descriptions[category] || ''; } + /** + * Build context from app_spec.txt for suggestion generation + * Extracts project name, overview, capabilities, and implemented features + */ + private async buildAppSpecContext(projectPath: string): Promise { + try { + const specPath = getAppSpecPath(projectPath); + const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + + const parts: string[] = []; + parts.push('## App Specification'); + + // Extract project name + const projectNames = extractXmlElements(specContent, 'project_name'); + if (projectNames.length > 0 && projectNames[0]) { + parts.push(`**Project:** ${projectNames[0]}`); + } + + // Extract overview + const overviews = extractXmlElements(specContent, 'overview'); + if (overviews.length > 0 && overviews[0]) { + parts.push(`**Overview:** ${overviews[0]}`); + } + + // Extract core capabilities + const capabilities = extractXmlElements(specContent, 'capability'); + if (capabilities.length > 0) { + parts.push('**Core Capabilities:**'); + for (const cap of capabilities) { + parts.push(`- ${cap}`); + } + } + + // Extract implemented features + const implementedFeatures = extractImplementedFeatures(specContent); + if (implementedFeatures.length > 0) { + parts.push('**Implemented Features:**'); + for (const feature of implementedFeatures) { + if (feature.description) { + parts.push(`- ${feature.name}: ${feature.description}`); + } else { + parts.push(`- ${feature.name}`); + } + } + } + + // Only return content if we extracted something meaningful + if (parts.length > 1) { + return parts.join('\n'); + } + return ''; + } catch (error) { + // If file doesn't exist, return empty string silently + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + // For other errors, log and return empty string + logger.warn('Failed to build app spec context:', error); + return ''; + } + } + /** * Gather basic project information for context when no context files exist */ @@ -1440,11 +1534,15 @@ ${contextSection}${existingWorkSection}`; * Gather existing features and ideas to prevent duplicate suggestions * Returns a concise list of titles grouped by status to avoid polluting context */ - private async gatherExistingWorkContext(projectPath: string): Promise { + private async gatherExistingWorkContext( + projectPath: string, + options?: { includeFeatures?: boolean; includeIdeas?: boolean } + ): Promise { + const { includeFeatures = true, includeIdeas = true } = options ?? {}; const parts: string[] = []; // Load existing features from the board - if (this.featureLoader) { + if (includeFeatures && this.featureLoader) { try { const features = await this.featureLoader.getAll(projectPath); if (features.length > 0) { @@ -1492,34 +1590,36 @@ ${contextSection}${existingWorkSection}`; } // Load existing ideas - try { - const ideas = await this.getIdeas(projectPath); - // Filter out archived ideas - const activeIdeas = ideas.filter((idea) => idea.status !== 'archived'); + if (includeIdeas) { + try { + const ideas = await this.getIdeas(projectPath); + // Filter out archived ideas + const activeIdeas = ideas.filter((idea) => idea.status !== 'archived'); - if (activeIdeas.length > 0) { - parts.push('## Existing Ideas (Do NOT regenerate these)'); - parts.push( - 'The following ideas have already been captured. Do NOT suggest similar ideas:\n' - ); + if (activeIdeas.length > 0) { + parts.push('## Existing Ideas (Do NOT regenerate these)'); + parts.push( + 'The following ideas have already been captured. Do NOT suggest similar ideas:\n' + ); - // Group by category for organization - const byCategory: Record = {}; - for (const idea of activeIdeas) { - const cat = idea.category || 'feature'; - if (!byCategory[cat]) { - byCategory[cat] = []; + // Group by category for organization + const byCategory: Record = {}; + for (const idea of activeIdeas) { + const cat = idea.category || 'feature'; + if (!byCategory[cat]) { + byCategory[cat] = []; + } + byCategory[cat].push(idea.title); } - byCategory[cat].push(idea.title); - } - for (const [category, titles] of Object.entries(byCategory)) { - parts.push(`**${category}:** ${titles.join(', ')}`); + for (const [category, titles] of Object.entries(byCategory)) { + parts.push(`**${category}:** ${titles.join(', ')}`); + } + parts.push(''); } - parts.push(''); + } catch (error) { + logger.warn('Failed to load existing ideas:', error); } - } catch (error) { - logger.warn('Failed to load existing ideas:', error); } return parts.join('\n'); diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts index 6b862fa5..1be24cbe 100644 --- a/apps/server/tests/unit/services/ideation-service.test.ts +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -15,7 +15,7 @@ import type { } from '@automaker/types'; import { ProviderFactory } from '@/providers/provider-factory.js'; -// Create a shared mock logger instance for assertions using vi.hoisted +// Create shared mock instances for assertions using vi.hoisted const mockLogger = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), @@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({ debug: vi.fn(), })); +const mockCreateChatOptions = vi.hoisted(() => + vi.fn(() => ({ + model: 'claude-sonnet-4-20250514', + systemPrompt: 'test prompt', + })) +); + // Mock dependencies vi.mock('@/lib/secure-fs.js'); vi.mock('@automaker/platform'); @@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => { }); vi.mock('@/providers/provider-factory.js'); vi.mock('@/lib/sdk-options.js', () => ({ - createChatOptions: vi.fn(() => ({ - model: 'claude-sonnet-4-20250514', - systemPrompt: 'test prompt', - })), + createChatOptions: mockCreateChatOptions, validateWorkingDirectory: vi.fn(), })); @@ -786,6 +790,143 @@ describe('IdeationService', () => { service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5) ).rejects.toThrow('Prompt non-existent not found'); }); + + it('should include app spec context when useAppSpec is enabled', async () => { + const mockAppSpec = ` + + Test Project + A test application for unit testing + + User authentication + Data visualization + + + + Login System + Basic auth with email/password + + + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + // First call returns app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(mockAppSpec) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).toContain('Test Project'); + expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing'); + expect(chatOptionsCall.systemPrompt).toContain('User authentication'); + expect(chatOptionsCall.systemPrompt).toContain('Login System'); + }); + + it('should exclude app spec context when useAppSpec is disabled', async () => { + const mockAppSpec = ` + + Hidden Project + This should not appear + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: false, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt NOT containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project'); + expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear'); + }); + + it('should handle missing app spec file gracefully', async () => { + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + enoentError.code = 'ENOENT'; + + // First call fails with ENOENT for app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockRejectedValueOnce(enoentError) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + + // Should not throw + await expect( + service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }) + ).resolves.toBeDefined(); + + // Should not log warning for ENOENT + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/ui/package.json b/apps/ui/package.json index 49087048..32e2ebbd 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -107,6 +107,7 @@ "sonner": "2.0.7", "tailwind-merge": "3.4.0", "usehooks-ts": "3.1.1", + "zod": "^3.24.1 || ^4.0.0", "zustand": "5.0.9" }, "optionalDependencies": { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts index 6a3276ec..ab3a87d0 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -95,12 +95,20 @@ export function useWorktrees({ ); // fetchWorktrees for backward compatibility - now just triggers a refetch - const fetchWorktrees = useCallback(async () => { - await queryClient.invalidateQueries({ - queryKey: queryKeys.worktrees.all(projectPath), - }); - return refetch(); - }, [projectPath, queryClient, refetch]); + // The silent option is accepted but not used (React Query handles loading states) + // Returns removed worktrees array if any were detected, undefined otherwise + const fetchWorktrees = useCallback( + async (_options?: { + silent?: boolean; + }): Promise | undefined> => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), + }); + const result = await refetch(); + return result.data?.removedWorktrees; + }, + [projectPath, queryClient, refetch] + ); const currentWorktreePath = currentWorktree?.path ?? null; const selectedWorktree = currentWorktreePath diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index b1e800fe..6d376ea5 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -383,13 +383,13 @@ export function WorktreePanel({ const isMobile = useIsMobile(); - // Periodic interval check (5 seconds) to detect branch changes on disk - // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders + // Periodic interval check (30 seconds) to detect branch changes on disk + // Reduced polling to lessen repeated worktree list calls while keeping UI reasonably fresh const intervalRef = useRef(null); useEffect(() => { intervalRef.current = setInterval(() => { fetchWorktrees({ silent: true }); - }, 5000); + }, 30000); return () => { if (intervalRef.current) { diff --git a/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx b/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx new file mode 100644 index 00000000..ac365051 --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx @@ -0,0 +1,136 @@ +/** + * IdeationSettingsPopover - Configure context sources for idea generation + */ + +import { useMemo } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Settings2, FileText, Brain, LayoutGrid, Lightbulb, ScrollText } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; +import { useIdeationStore } from '@/store/ideation-store'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES, type IdeationContextSources } from '@automaker/types'; + +interface IdeationSettingsPopoverProps { + projectPath: string; +} + +const IDEATION_CONTEXT_OPTIONS: Array<{ + key: keyof IdeationContextSources; + label: string; + description: string; + icon: typeof FileText; +}> = [ + { + key: 'useAppSpec', + label: 'App Specification', + description: 'Overview, capabilities, features', + icon: ScrollText, + }, + { + key: 'useContextFiles', + label: 'Context Files', + description: '.automaker/context/*.md|.txt', + icon: FileText, + }, + { + key: 'useMemoryFiles', + label: 'Memory Files', + description: '.automaker/memory/*.md', + icon: Brain, + }, + { + key: 'useExistingFeatures', + label: 'Existing Features', + description: 'Board features list', + icon: LayoutGrid, + }, + { + key: 'useExistingIdeas', + label: 'Existing Ideas', + description: 'Ideation ideas list', + icon: Lightbulb, + }, +]; + +/** + * Renders a settings popover to toggle per-project ideation context sources. + * Merges defaults with stored overrides and persists changes via the ideation store. + */ +export function IdeationSettingsPopover({ projectPath }: IdeationSettingsPopoverProps) { + const { projectOverrides, setContextSource } = useIdeationStore( + useShallow((state) => ({ + projectOverrides: state.contextSourcesByProject[projectPath], + setContextSource: state.setContextSource, + })) + ); + const contextSources = useMemo( + () => ({ ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...projectOverrides }), + [projectOverrides] + ); + + return ( + + + + + +
+
+

Generation Settings

+

+ Configure which context sources are included when generating ideas. +

+
+ +
+ {IDEATION_CONTEXT_OPTIONS.map((option) => { + const Icon = option.icon; + return ( +
+
+ +
+ + + {option.description} + +
+
+ + setContextSource(projectPath, option.key, checked) + } + data-testid={`ideation-context-toggle-${option.key}`} + /> +
+ ); + })} +
+ +

+ Disable sources to generate more focused ideas or reduce context size. +

+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index 50cbd8d3..ec2e251c 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -13,6 +13,7 @@ import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { Button } from '@/components/ui/button'; import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; +import { IdeationSettingsPopover } from './components/ideation-settings-popover'; import type { IdeaCategory } from '@automaker/types'; import type { IdeationMode } from '@/store/ideation-store'; @@ -61,7 +62,10 @@ function IdeationBreadcrumbs({ ); } -// Header shown on all pages - matches other view headers +/** + * Header component for the ideation view with navigation, bulk actions, and settings. + * Displays breadcrumbs, accept/discard all buttons, and the generate ideas button with settings popover. + */ function IdeationHeader({ currentMode, selectedCategory, @@ -75,6 +79,7 @@ function IdeationHeader({ discardAllReady, discardAllCount, onDiscardAll, + projectPath, }: { currentMode: IdeationMode; selectedCategory: IdeaCategory | null; @@ -88,6 +93,7 @@ function IdeationHeader({ discardAllReady: boolean; discardAllCount: number; onDiscardAll: () => void; + projectPath: string; }) { const { getCategoryById } = useGuidedPrompts(); const showBackButton = currentMode === 'prompts'; @@ -157,15 +163,23 @@ function IdeationHeader({ Accept All ({acceptAllCount}) )} - +
+ + +
); } +/** + * Main view for brainstorming and idea management. + * Provides a dashboard for reviewing generated ideas and a prompt selection flow + * for generating new ideas using AI-powered suggestions. + */ export function IdeationView() { const currentProject = useAppStore((s) => s.currentProject); const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore(); @@ -282,6 +296,7 @@ export function IdeationView() { discardAllReady={discardAllReady} discardAllCount={discardAllCount} onDiscardAll={handleDiscardAll} + projectPath={currentProject.path} /> {/* Dashboard - main view */} diff --git a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx index 5102d243..1a150500 100644 --- a/apps/ui/src/components/views/project-settings-view/project-models-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-models-section.tsx @@ -104,7 +104,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) { const hasOverride = !!projectOverride; const effectiveValue = projectOverride || globalValue; - // Get display name for a model + /** + * Formats a user-friendly model label using provider metadata when available, + * falling back to known Claude aliases or the raw model id. + */ const getModelDisplayName = (entry: PhaseModelEntry): string => { if (entry.providerId) { const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId); @@ -127,10 +130,16 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) { return modelMap[entry.model] || entry.model; }; + /** + * Clears the project-level model override for this scope. + */ const handleClearOverride = () => { setProjectDefaultFeatureModel(project.id, null); }; + /** + * Sets the project-level model override for this scope. + */ const handleSetOverride = (entry: PhaseModelEntry) => { setProjectDefaultFeatureModel(project.id, entry); }; @@ -209,6 +218,10 @@ function FeatureDefaultModelOverrideSection({ project }: { project: Project }) { ); } +/** + * Renders a single phase override row, showing the effective model + * (project override or global default) and wiring selector/reset actions. + */ function PhaseOverrideItem({ phase, project, @@ -225,7 +238,10 @@ function PhaseOverrideItem({ const hasOverride = !!projectOverride; const effectiveValue = projectOverride || globalValue; - // Get display name for a model + /** + * Formats a user-friendly model label using provider metadata when available, + * falling back to known Claude aliases or the raw model id. + */ const getModelDisplayName = (entry: PhaseModelEntry): string => { if (entry.providerId) { const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId); @@ -248,10 +264,16 @@ function PhaseOverrideItem({ return modelMap[entry.model] || entry.model; }; + /** + * Clears the project-level model override for this scope. + */ const handleClearOverride = () => { setProjectPhaseModelOverride(project.id, phase.key, null); }; + /** + * Sets the project-level model override for this scope. + */ const handleSetOverride = (entry: PhaseModelEntry) => { setProjectPhaseModelOverride(project.id, phase.key, entry); }; @@ -315,6 +337,10 @@ function PhaseOverrideItem({ ); } +/** + * Renders a titled group of phase override rows and resolves each phase's + * global default model with a fallback to DEFAULT_PHASE_MODELS. + */ function PhaseGroup({ title, subtitle, @@ -350,9 +376,11 @@ function PhaseGroup({ ); } +/** + * Renders the per-project model overrides UI for all phase models. + */ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { - const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } = - useAppStore(); + const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); // Count how many overrides are set (including defaultFeatureModel) @@ -360,25 +388,13 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { const hasDefaultFeatureModelOverride = !!project.defaultFeatureModel; const overrideCount = phaseOverrideCount + (hasDefaultFeatureModelOverride ? 1 : 0); - // Check if Claude is available - const isClaudeDisabled = disabledProviders.includes('claude'); - // Check if there are any enabled ClaudeCompatibleProviders const hasEnabledProviders = claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false); - if (isClaudeDisabled) { - return ( -
- -

Claude not configured

-

- Enable Claude in global settings to configure per-project model overrides. -

-
- ); - } - + /** + * Clears all project-level phase model overrides for this project. + */ const handleClearAll = () => { clearAllProjectPhaseModelOverrides(project.id); }; diff --git a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts index 2c81b3ee..10cdfab7 100644 --- a/apps/ui/src/hooks/mutations/use-ideation-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-ideation-mutations.ts @@ -68,7 +68,16 @@ export function useGenerateIdeationSuggestions(projectPath: string) { throw new Error('Ideation API not available'); } - const result = await api.ideation.generateSuggestions(projectPath, promptId, category); + // Get context sources from store + const contextSources = useIdeationStore.getState().getContextSources(projectPath); + + const result = await api.ideation.generateSuggestions( + projectPath, + promptId, + category, + undefined, // count - use default + contextSources + ); if (!result.success) { throw new Error(result.error || 'Failed to generate suggestions'); diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index b2065b2b..812def33 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -27,6 +27,7 @@ import type { CreateIdeaInput, UpdateIdeaInput, ConvertToFeatureOptions, + IdeationContextSources, } from '@automaker/types'; import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; @@ -114,7 +115,8 @@ export interface IdeationAPI { projectPath: string, promptId: string, category: IdeaCategory, - count?: number + count?: number, + contextSources?: IdeationContextSources ) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>; // Convert to feature diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1ef03fee..c78a8642 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -32,6 +32,7 @@ import type { NotificationsAPI, EventHistoryAPI, } from './electron'; +import type { IdeationContextSources } from '@automaker/types'; import type { EventHistoryFilter } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; @@ -2739,9 +2740,16 @@ export class HttpApiClient implements ElectronAPI { projectPath: string, promptId: string, category: IdeaCategory, - count?: number + count?: number, + contextSources?: IdeationContextSources ) => - this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }), + this.post('/api/ideation/suggestions/generate', { + projectPath, + promptId, + category, + count, + contextSources, + }), convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) => this.post('/api/ideation/convert', { projectPath, ideaId, ...options }), diff --git a/apps/ui/src/store/ideation-store.ts b/apps/ui/src/store/ideation-store.ts index fd292299..9e4f135b 100644 --- a/apps/ui/src/store/ideation-store.ts +++ b/apps/ui/src/store/ideation-store.ts @@ -11,7 +11,9 @@ import type { IdeationPrompt, AnalysisSuggestion, ProjectAnalysisResult, + IdeationContextSources, } from '@automaker/types'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; // ============================================================================ // Generation Job Types @@ -61,6 +63,9 @@ interface IdeationState { currentMode: IdeationMode; selectedCategory: IdeaCategory | null; filterStatus: IdeaStatus | 'all'; + + // Context sources per project + contextSourcesByProject: Record>; } // ============================================================================ @@ -110,6 +115,21 @@ interface IdeationActions { 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; @@ -135,6 +155,7 @@ const initialState: IdeationState = { currentMode: 'dashboard', selectedCategory: null, filterStatus: 'all', + contextSourcesByProject: {}, }; // ============================================================================ @@ -300,6 +321,24 @@ export const useIdeationStore = create()( 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), @@ -313,13 +352,14 @@ export const useIdeationStore = create()( }), { name: 'automaker-ideation-store', - version: 4, + 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; @@ -331,6 +371,13 @@ export const useIdeationStore = create()( generationJobs: jobs.filter((job) => job.projectPath !== undefined), }; } + if (version < 5) { + // Initialize contextSourcesByProject if not present + return { + ...state, + contextSourcesByProject: state.contextSourcesByProject ?? {}, + }; + } return state; }, } diff --git a/libs/types/src/ideation.ts b/libs/types/src/ideation.ts index c1c80903..be2c5228 100644 --- a/libs/types/src/ideation.ts +++ b/libs/types/src/ideation.ts @@ -228,3 +228,35 @@ export interface IdeationAnalysisEvent { result?: ProjectAnalysisResult; error?: string; } + +// ============================================================================ +// Context Sources Configuration +// ============================================================================ + +/** + * Configuration for which context sources to include when generating ideas. + * All values default to true for backward compatibility. + */ +export interface IdeationContextSources { + /** Include .automaker/context/*.md|.txt files */ + useContextFiles: boolean; + /** Include .automaker/memory/*.md files */ + useMemoryFiles: boolean; + /** Include existing features from the board */ + useExistingFeatures: boolean; + /** Include existing ideas from ideation */ + useExistingIdeas: boolean; + /** Include app specification (.automaker/app_spec.txt) */ + useAppSpec: boolean; +} + +/** + * Default context sources configuration - all enabled for backward compatibility + */ +export const DEFAULT_IDEATION_CONTEXT_SOURCES: IdeationContextSources = { + useContextFiles: true, + useMemoryFiles: true, + useExistingFeatures: true, + useExistingIdeas: true, + useAppSpec: true, +}; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 4f0d48e9..d6d305fe 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -328,7 +328,9 @@ export type { IdeationEventType, IdeationStreamEvent, IdeationAnalysisEvent, + IdeationContextSources, } from './ideation.js'; +export { DEFAULT_IDEATION_CONTEXT_SOURCES } from './ideation.js'; // Notification types export type { NotificationType, Notification, NotificationsFile } from './notification.js'; diff --git a/libs/utils/src/context-loader.ts b/libs/utils/src/context-loader.ts index 3a981990..9f68e23b 100644 --- a/libs/utils/src/context-loader.ts +++ b/libs/utils/src/context-loader.ts @@ -97,6 +97,8 @@ export interface LoadContextFilesOptions { projectPath: string; /** Optional custom secure fs module (for dependency injection) */ fsModule?: ContextFsModule; + /** Whether to include context files from .automaker/context/ (default: true) */ + includeContextFiles?: boolean; /** Whether to include memory files from .automaker/memory/ (default: true) */ includeMemory?: boolean; /** Whether to initialize memory folder if it doesn't exist (default: true) */ @@ -210,6 +212,7 @@ export async function loadContextFiles( const { projectPath, fsModule = secureFs, + includeContextFiles = true, includeMemory = true, initializeMemory = true, taskContext, @@ -220,42 +223,44 @@ export async function loadContextFiles( const files: ContextFileInfo[] = []; const memoryFiles: MemoryFileInfo[] = []; - // Load context files - try { - // Check if directory exists - await fsModule.access(contextDir); + // Load context files if enabled + if (includeContextFiles) { + try { + // Check if directory exists + await fsModule.access(contextDir); - // Read directory contents - const allFiles = await fsModule.readdir(contextDir); + // Read directory contents + const allFiles = await fsModule.readdir(contextDir); - // Filter for text-based context files (case-insensitive for cross-platform) - const textFiles = allFiles.filter((f) => { - const lower = f.toLowerCase(); - return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; - }); + // Filter for text-based context files (case-insensitive for cross-platform) + const textFiles = allFiles.filter((f) => { + const lower = f.toLowerCase(); + return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json'; + }); - if (textFiles.length > 0) { - // Load metadata for descriptions - const metadata = await loadContextMetadata(contextDir, fsModule); + if (textFiles.length > 0) { + // Load metadata for descriptions + const metadata = await loadContextMetadata(contextDir, fsModule); - // Load each file with its content and metadata - for (const fileName of textFiles) { - const filePath = path.join(contextDir, fileName); - try { - const content = await fsModule.readFile(filePath, 'utf-8'); - files.push({ - name: fileName, - path: filePath, - content: content as string, - description: metadata.files[fileName]?.description, - }); - } catch (error) { - console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error); + // Load each file with its content and metadata + for (const fileName of textFiles) { + const filePath = path.join(contextDir, fileName); + try { + const content = await fsModule.readFile(filePath, 'utf-8'); + files.push({ + name: fileName, + path: filePath, + content: content as string, + description: metadata.files[fileName]?.description, + }); + } catch (error) { + console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error); + } } } + } catch { + // Context directory doesn't exist or is inaccessible - that's fine } - } catch { - // Context directory doesn't exist or is inaccessible - that's fine } // Load memory files if enabled (with smart selection)