From 735786701f633d9979728ab58d9821702d637c35 Mon Sep 17 00:00:00 2001 From: ruant Date: Fri, 23 Jan 2026 19:29:46 +0100 Subject: [PATCH 01/17] fix(docker): add missing copy of spec-parser in docker --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index e0afeb74..42d179d9 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/spep-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/ From 92f2702f3b4da5317b2304aa7c3c0effcb499b24 Mon Sep 17 00:00:00 2001 From: ruant Date: Fri, 23 Jan 2026 19:30:36 +0100 Subject: [PATCH 02/17] fix(build): add missing "npm run build" in build script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1a772c33..8d384529 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev:docker:rebuild": "docker compose build --no-cache && docker compose up", "dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"", "build": "npm run build:packages && npm run build --workspace=apps/ui", - "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", + "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", "build:server": "npm run build:packages && npm run build --workspace=apps/server", "build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui", "build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui", From 907c1d65b34bd444607fd6546f8509de39e180ba Mon Sep 17 00:00:00 2001 From: ruant Date: Fri, 23 Jan 2026 19:30:57 +0100 Subject: [PATCH 03/17] fix(deps): add missing zod dependency --- apps/ui/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ui/package.json b/apps/ui/package.json index 1e2a0d02..ebe233fe 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -105,6 +105,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": { From 140c444e6f1bdb42cf0d49881a83accefd143fde Mon Sep 17 00:00:00 2001 From: ruant Date: Fri, 23 Jan 2026 19:38:38 +0100 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20typo=20=F0=9F=A4=A6=E2=80=8D?= =?UTF-8?q?=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 42d179d9..fec7aa49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +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/spep-parser/package*.json ./libs/spec-parser/ +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/ From f3b16ad8ce3aea4613039f672afee84cc106ba6f Mon Sep 17 00:00:00 2001 From: ruant Date: Fri, 23 Jan 2026 19:43:30 +0100 Subject: [PATCH 05/17] revert: fix not needed --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d384529..1a772c33 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev:docker:rebuild": "docker compose build --no-cache && docker compose up", "dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"", "build": "npm run build:packages && npm run build --workspace=apps/ui", - "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", + "build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils", "build:server": "npm run build:packages && npm run build --workspace=apps/server", "build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui", "build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui", From 5a3dac1533914b72cfcbed31ad38bebe115919e1 Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 12:30:20 +0100 Subject: [PATCH 06/17] feat: Add ideation context settings - Add settings popover to the ideation view - Migrate previous context to toggles (memory, context, features, ideas) - Add app specifications as new context option --- .../ideation/routes/suggestions-generate.ts | 6 +- apps/server/src/services/ideation-service.ts | 194 +++++++++++++----- .../unit/services/ideation-service.test.ts | 151 +++++++++++++- .../components/ideation-settings-popover.tsx | 132 ++++++++++++ .../components/views/ideation-view/index.tsx | 15 +- .../hooks/mutations/use-ideation-mutations.ts | 11 +- apps/ui/src/lib/electron.ts | 4 +- apps/ui/src/lib/http-api-client.ts | 12 +- apps/ui/src/store/ideation-store.ts | 42 +++- libs/types/src/ideation.ts | 32 +++ libs/types/src/index.ts | 2 + libs/utils/src/context-loader.ts | 63 +++--- 12 files changed, 573 insertions(+), 91 deletions(-) create mode 100644 apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts index 8add2af5..1aa7487b 100644 --- a/apps/server/src/routes/ideation/routes/suggestions-generate.ts +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -4,6 +4,7 @@ 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'; @@ -12,7 +13,7 @@ const logger = createLogger('ideation:suggestions-generate'); 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 +39,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic projectPath, promptId, category, - suggestionCount + suggestionCount, + contextSources as IdeationContextSources | undefined ); res.json({ diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 990a4552..dbfd1cc0 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,49 @@ ${contextSection}${existingWorkSection}`; */ private parseSuggestionsFromResponse( response: string, - category: IdeaCategory + category: IdeaCategory, + count: number = 10 ): AnalysisSuggestion[] { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); 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, suggestionCount); } const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, suggestionCount); } - 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, suggestionCount); } catch (error) { logger.warn('Failed to parse JSON response:', error); - return this.parseTextResponse(response, category); + return this.parseTextResponse(response, category, suggestionCount); } } /** * Fallback: parse text response into suggestions */ - private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] { + private parseTextResponse( + response: string, + category: IdeaCategory, + count: number = 10 + ): AnalysisSuggestion[] { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); const suggestions: AnalysisSuggestion[] = []; // Try to find numbered items or headers @@ -907,7 +941,7 @@ ${contextSection}${existingWorkSection}`; }); } - return suggestions.slice(0, 5); // Max 5 suggestions + return suggestions.slice(0, suggestionCount); } // ============================================================================ @@ -1345,6 +1379,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 +1536,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 +1592,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/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..a96becdd --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/components/ideation-settings-popover.tsx @@ -0,0 +1,132 @@ +/** + * 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, + }, +]; + +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..1346d925 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'; @@ -75,6 +76,7 @@ function IdeationHeader({ discardAllReady, discardAllCount, onDiscardAll, + projectPath, }: { currentMode: IdeationMode; selectedCategory: IdeaCategory | null; @@ -88,6 +90,7 @@ function IdeationHeader({ discardAllReady: boolean; discardAllCount: number; onDiscardAll: () => void; + projectPath: string; }) { const { getCategoryById } = useGuidedPrompts(); const showBackButton = currentMode === 'prompts'; @@ -157,10 +160,13 @@ function IdeationHeader({ Accept All ({acceptAllCount}) )} - +
+ + +
); @@ -282,6 +288,7 @@ export function IdeationView() { discardAllReady={discardAllReady} discardAllCount={discardAllCount} onDiscardAll={handleDiscardAll} + projectPath={currentProject.path} /> {/* Dashboard - main view */} 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..dde9bdf6 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,14 @@ interface IdeationActions { setCategory: (category: IdeaCategory | null) => void; setFilterStatus: (status: IdeaStatus | 'all') => void; + // Context sources + getContextSources: (projectPath: string) => IdeationContextSources; + setContextSource: ( + projectPath: string, + key: keyof IdeationContextSources, + value: boolean + ) => void; + // Reset reset: () => void; resetSuggestions: () => void; @@ -135,6 +148,7 @@ const initialState: IdeationState = { currentMode: 'dashboard', selectedCategory: null, filterStatus: 'all', + contextSourcesByProject: {}, }; // ============================================================================ @@ -300,6 +314,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 +345,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 +364,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 a4a7635e..e7ae5ba3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -325,7 +325,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) From 1e87b73dfd19464d163f28175b7206a24d005c97 Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 13:01:48 +0100 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20Remove=20redundant=20count=20?= =?UTF-8?q?normalization=20in=20suggestion=20parsing=20-=20Removed=20the?= =?UTF-8?q?=20suggestionCount=20variable=20that=20was=20re-clamping=20the?= =?UTF-8?q?=20count=20parameter=20-=20Removed=20default=20values=20from=20?= =?UTF-8?q?function=20parameters=20(count:=20number=20=3D=2010=20=E2=86=92?= =?UTF-8?q?=20count:=20number)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/services/ideation-service.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index dbfd1cc0..62edeaae 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -840,20 +840,19 @@ ${contextSection}${existingWorkSection}`; private parseSuggestionsFromResponse( response: string, category: IdeaCategory, - count: number = 10 + count: number ): AnalysisSuggestion[] { - const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); 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, suggestionCount); + return this.parseTextResponse(response, category, count); } const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { - return this.parseTextResponse(response, category, suggestionCount); + return this.parseTextResponse(response, category, count); } return parsed @@ -866,10 +865,10 @@ ${contextSection}${existingWorkSection}`; priority: item.priority || 'medium', relatedFiles: item.relatedFiles || [], })) - .slice(0, suggestionCount); + .slice(0, count); } catch (error) { logger.warn('Failed to parse JSON response:', error); - return this.parseTextResponse(response, category, suggestionCount); + return this.parseTextResponse(response, category, count); } } @@ -879,9 +878,8 @@ ${contextSection}${existingWorkSection}`; private parseTextResponse( response: string, category: IdeaCategory, - count: number = 10 + count: number ): AnalysisSuggestion[] { - const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); const suggestions: AnalysisSuggestion[] = []; // Try to find numbered items or headers @@ -941,7 +939,7 @@ ${contextSection}${existingWorkSection}`; }); } - return suggestions.slice(0, suggestionCount); + return suggestions.slice(0, count); } // ============================================================================ From 1ecb97b71c641d0216a7803b4180a4290933dc78 Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 13:13:11 +0100 Subject: [PATCH 08/17] docs: Add docstrings for ideation context settings --- .../ideation-view/components/ideation-settings-popover.tsx | 4 ++++ apps/ui/src/store/ideation-store.ts | 7 +++++++ 2 files changed, 11 insertions(+) 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 index a96becdd..a5dc3195 100644 --- 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 @@ -53,6 +53,10 @@ const IDEATION_CONTEXT_OPTIONS: Array<{ }, ]; +/** + * 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) => ({ diff --git a/apps/ui/src/store/ideation-store.ts b/apps/ui/src/store/ideation-store.ts index dde9bdf6..9e4f135b 100644 --- a/apps/ui/src/store/ideation-store.ts +++ b/apps/ui/src/store/ideation-store.ts @@ -116,7 +116,14 @@ interface IdeationActions { 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, From a3c62e8358341b579d984ec5019a8983165fed7e Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 13:30:09 +0100 Subject: [PATCH 09/17] docs: Add docstrings for ideation route handler and view components --- .../src/routes/ideation/routes/suggestions-generate.ts | 5 +++++ apps/ui/src/components/views/ideation-view/index.tsx | 10 +++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts index 1aa7487b..ffb4e8ac 100644 --- a/apps/server/src/routes/ideation/routes/suggestions-generate.ts +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -10,6 +10,11 @@ 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 { diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index 1346d925..39f72b04 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -62,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, @@ -172,6 +175,11 @@ function IdeationHeader({ ); } +/** + * 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(); From f8108b1a6c76ae28dcc008c800927a763c344b7c Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 21:23:30 +0100 Subject: [PATCH 10/17] fix: Remove mandatory Claude check for Project Settings -> Models --- .../project-models-section.tsx | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) 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..6dc7fd2b 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 @@ -349,7 +349,9 @@ function PhaseGroup({ ); } - +/** + * Renders the per-project model overrides UI for all phase models. + */ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } = useAppStore(); @@ -360,25 +362,10 @@ 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. -

-
- ); - } - const handleClearAll = () => { clearAllProjectPhaseModelOverrides(project.id); }; From 43d5ec9aed80da772f535462262214b9fb566a98 Mon Sep 17 00:00:00 2001 From: Monoquark <24584001+Monoquark@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:37:18 +0100 Subject: [PATCH 11/17] refactor: Remove unused disableProviders variable Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../views/project-settings-view/project-models-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6dc7fd2b..94ecd077 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 @@ -353,7 +353,7 @@ function PhaseGroup({ * Renders the per-project model overrides UI for all phase models. */ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { - const { clearAllProjectPhaseModelOverrides, disabledProviders, claudeCompatibleProviders } = + const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); From c401bf4e637b754bd0a1c2828d1ed775bceb7c6f Mon Sep 17 00:00:00 2001 From: Monoquark Date: Sat, 24 Jan 2026 21:52:46 +0100 Subject: [PATCH 12/17] docs: Add docstrings for project models selection --- .../project-models-section.tsx | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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 94ecd077..2e9bf40b 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, @@ -349,6 +375,7 @@ function PhaseGroup({ ); } + /** * Renders the per-project model overrides UI for all phase models. */ @@ -366,6 +393,9 @@ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { const hasEnabledProviders = claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false); + /** + * Clears all project-level phase model overrides for this project. + */ const handleClearAll = () => { clearAllProjectPhaseModelOverrides(project.id); }; From f5efa857ca498bd20cd9b8e390a805915bf5e276 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 22:05:29 +0100 Subject: [PATCH 13/17] fix: Prevent GitHub API rate limiting from frequent worktree PR fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #685 This commit addresses the GitHub API rate limit issue caused by excessive worktree PR status fetching. ## Changes ### Server-side PR caching (list.ts) - Added `GitHubPRCacheEntry` interface and `githubPRCache` Map - Implemented 2-minute TTL cache for GitHub PR data - Modified `fetchGitHubPRs()` to check cache before making API calls - Added `forceRefresh` parameter to bypass cache when explicitly requested - Cache is properly cleared when force refresh is triggered ### Frontend polling reduction (worktree-panel.tsx) - Increased worktree polling interval from 5 seconds to 30 seconds - Reduces polling frequency by 6x while keeping UI reasonably fresh - Updated comment to reflect new polling strategy ### Type improvements (use-worktrees.ts) - Fixed `fetchWorktrees` callback signature to accept `silent` option - Returns proper type for removed worktrees detection ## Impact - Combined ~12x reduction in GitHub API calls - 2-minute cache prevents repeated API hits during normal operation - 30-second polling balances responsiveness with API conservation - Force refresh option allows users to manually update when needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../server/src/routes/worktree/routes/list.ts | 33 +++++++++++++++++-- .../worktree-panel/hooks/use-worktrees.ts | 20 +++++++---- .../worktree-panel/worktree-panel.tsx | 6 ++-- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index f0d9c030..8482f62c 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,24 @@ async function getGitHubRemoteStatus(projectPath: string): Promise> { +async function fetchGitHubPRs( + projectPath: string, + forceRefresh = false +): Promise> { + const now = Date.now(); + + if (!forceRefresh) { + const cached = githubPRCache.get(projectPath); + if (cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) { + return cached.prs; + } + } else { + githubPRCache.delete(projectPath); + } + const prMap = new Map(); try { @@ -225,6 +247,11 @@ async function fetchGitHubPRs(projectPath: string): Promise(); for (const worktree of worktrees) { 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) { From b5143f4b005802098a93b67707c1407c7ee8bf58 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 22:27:58 +0100 Subject: [PATCH 14/17] fix: Return stale cache on GitHub PR fetch failure to prevent repeated API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #688 review feedback: previously the cache was deleted before fetch, causing repeated API calls if the fetch failed. Now the cache entry is preserved and stale data is returned on failure, preventing unnecessary API calls during GitHub API flakiness or temporary outages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../server/src/routes/worktree/routes/list.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 8482f62c..bb9e5d8f 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -195,14 +195,11 @@ async function fetchGitHubPRs( forceRefresh = false ): Promise> { const now = Date.now(); + const cached = githubPRCache.get(projectPath); - if (!forceRefresh) { - const cached = githubPRCache.get(projectPath); - if (cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) { - return cached.prs; - } - } else { - githubPRCache.delete(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(); @@ -248,12 +245,19 @@ async function fetchGitHubPRs( }); } + // Only update cache on successful fetch githubPRCache.set(projectPath, { prs: prMap, fetchedAt: Date.now(), }); } catch (error) { - // Silently fail - PR detection is optional + // On fetch failure, return stale cached data if available to avoid + // repeated API calls during GitHub API flakiness or temporary outages + if (cached) { + logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`); + return cached.prs; + } + // No cache available, log warning and return empty map logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`); } From 8dd6ab2161797f6118fb6715346ab3b0e3aba60a Mon Sep 17 00:00:00 2001 From: Shirone Date: Sat, 24 Jan 2026 22:38:50 +0100 Subject: [PATCH 15/17] fix: Extend cache TTL on GitHub PR fetch failure to prevent retry storms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #688 review feedback from CodeRabbit: When a GitHub PR fetch fails and we return stale cached data, also update the fetchedAt timestamp. This prevents the original TTL from expiring and causing every subsequent poll to retry the failing request, which would still hammer GitHub during API outages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/routes/worktree/routes/list.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index bb9e5d8f..0f8021f1 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -255,6 +255,8 @@ async function fetchGitHubPRs( // repeated API calls during GitHub API flakiness or temporary outages if (cached) { logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`); + // Extend cache TTL to avoid repeated retries during outages + githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() }); return cached.prs; } // No cache available, log warning and return empty map From 0f11ee221268e7ca9bf5bf42746c29e313e2a9e0 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 25 Jan 2026 01:41:18 +0100 Subject: [PATCH 16/17] chore: format --- .../views/project-settings-view/project-models-section.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 2e9bf40b..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 @@ -380,8 +380,7 @@ function PhaseGroup({ * Renders the per-project model overrides UI for all phase models. */ export function ProjectModelsSection({ project }: ProjectModelsSectionProps) { - const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = - useAppStore(); + const { clearAllProjectPhaseModelOverrides, claudeCompatibleProviders } = useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); // Count how many overrides are set (including defaultFeatureModel) From 7a5cb38a37a69ba51302f64c56e83c0815485dfc Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 25 Jan 2026 01:50:12 +0100 Subject: [PATCH 17/17] style: Adjust spacing in ideation header and update button styling in settings popover --- .../ideation-view/components/ideation-settings-popover.tsx | 2 +- apps/ui/src/components/views/ideation-view/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index a5dc3195..ac365051 100644 --- 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 @@ -74,7 +74,7 @@ export function IdeationSettingsPopover({ projectPath }: IdeationSettingsPopover