From d417666fe12c8b42f952a5868f3cdbfa7d62f19e Mon Sep 17 00:00:00 2001 From: antdev <237216263+yumesha@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:33:00 +0800 Subject: [PATCH 01/17] fix background image not showing --- .../dialogs/board-background-modal.tsx | 14 ++++---- .../ui/description-image-dropzone.tsx | 5 ++- .../board-view/hooks/use-board-background.ts | 16 ++++----- apps/ui/src/lib/api-fetch.ts | 34 +++++++++++++++++++ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index c1acdfd9..ae6bd714 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/lib/utils'; import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; -import { getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; import { toast } from 'sonner'; import { @@ -62,12 +63,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa // Update preview image when background settings change useEffect(() => { if (currentProject && backgroundSettings.imagePath) { - const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); // Add cache-busting query parameter to force browser to reload image - const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; - const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( - backgroundSettings.imagePath - )}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`; + const cacheBuster = imageVersion ?? Date.now().toString(); + const imagePath = getAuthenticatedImageUrl( + backgroundSettings.imagePath, + currentProject.path, + cacheBuster + ); setPreviewImage(imagePath); } else { setPreviewImage(null); diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index 9df5e0e6..78fe0346 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'; import { ImageIcon, X, Loader2, FileText } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; import { getElectronAPI } from '@/lib/electron'; -import { getServerUrlSync } from '@/lib/http-api-client'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store'; import { sanitizeFilename, @@ -94,9 +94,8 @@ export function DescriptionImageDropZone({ // Construct server URL for loading saved images const getImageServerUrl = useCallback( (imagePath: string): string => { - const serverUrl = import.meta.env.VITE_SERVER_URL || getServerUrlSync(); const projectPath = currentProject?.path || ''; - return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`; + return getAuthenticatedImageUrl(imagePath, projectPath); }, [currentProject?.path] ); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-background.ts b/apps/ui/src/components/views/board-view/hooks/use-board-background.ts index 5bd5f4f2..e61ba44c 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-background.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-background.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; -import { getServerUrlSync } from '@/lib/http-api-client'; +import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; interface UseBoardBackgroundProps { currentProject: { path: string; id: string } | null; @@ -22,14 +22,14 @@ export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) return {}; } + const imageUrl = getAuthenticatedImageUrl( + backgroundSettings.imagePath, + currentProject.path, + backgroundSettings.imageVersion + ); + return { - backgroundImage: `url(${ - import.meta.env.VITE_SERVER_URL || getServerUrlSync() - }/api/fs/image?path=${encodeURIComponent( - backgroundSettings.imagePath - )}&projectPath=${encodeURIComponent(currentProject.path)}${ - backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : '' - })`, + backgroundImage: `url(${imageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', diff --git a/apps/ui/src/lib/api-fetch.ts b/apps/ui/src/lib/api-fetch.ts index fc76c266..f3df93bf 100644 --- a/apps/ui/src/lib/api-fetch.ts +++ b/apps/ui/src/lib/api-fetch.ts @@ -153,3 +153,37 @@ export async function apiDeleteRaw( ): Promise { return apiFetch(endpoint, 'DELETE', options); } + +/** + * Build an authenticated image URL for use in tags or CSS background-image + * Adds authentication via query parameter since headers can't be set for image loads + * + * @param path - Image path + * @param projectPath - Project path + * @param version - Optional cache-busting version + * @returns Full URL with auth credentials + */ +export function getAuthenticatedImageUrl( + path: string, + projectPath: string, + version?: string | number +): string { + const serverUrl = getServerUrl(); + const params = new URLSearchParams({ + path, + projectPath, + }); + + if (version !== undefined) { + params.set('v', String(version)); + } + + // Add auth credential as query param (needed for image loads that can't set headers) + const apiKey = getApiKey(); + if (apiKey) { + params.set('apiKey', apiKey); + } + // Note: Session token auth relies on cookies which are sent automatically by the browser + + return `${serverUrl}/api/fs/image?${params.toString()}`; +} From 8d5e7b068c56dda635f254bed31563a0252818c1 Mon Sep 17 00:00:00 2001 From: antdev <237216263+yumesha@users.noreply.github.com> Date: Sat, 3 Jan 2026 09:55:54 +0800 Subject: [PATCH 02/17] fail format check fixed --- scripts/lint-lockfile.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/lint-lockfile.mjs b/scripts/lint-lockfile.mjs index b33c9780..d658905b 100644 --- a/scripts/lint-lockfile.mjs +++ b/scripts/lint-lockfile.mjs @@ -12,7 +12,7 @@ const lockfilePath = join(process.cwd(), 'package-lock.json'); try { const content = readFileSync(lockfilePath, 'utf8'); - + // Check for git+ssh:// URLs if (content.includes('git+ssh://')) { console.error('Error: package-lock.json contains git+ssh:// URLs.'); @@ -20,7 +20,7 @@ try { console.error('Or run: npm run fix:lockfile'); process.exit(1); } - + console.log('āœ“ No git+ssh:// URLs found in package-lock.json'); process.exit(0); } catch (error) { From 818d8af998ee33d3b5919621f9d4aca58d2c39c1 Mon Sep 17 00:00:00 2001 From: antdev <237216263+yumesha@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:47:23 +0800 Subject: [PATCH 03/17] E2E Test Fix - Ready for Manual Application --- apps/ui/tests/context/add-context-image.spec.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/ui/tests/context/add-context-image.spec.ts b/apps/ui/tests/context/add-context-image.spec.ts index bc40ec31..010707ab 100644 --- a/apps/ui/tests/context/add-context-image.spec.ts +++ b/apps/ui/tests/context/add-context-image.spec.ts @@ -118,21 +118,10 @@ test.describe('Add Context Image', () => { test('should import an image file to context', async ({ page }) => { await setupProjectWithFixture(page, getFixturePath()); - + await authenticateForTests(page); await page.goto('/'); await waitForNetworkIdle(page); - // Check if we're on the login screen and authenticate if needed - const loginInput = page.locator('input[type="password"][placeholder*="API key"]'); - const isLoginScreen = await loginInput.isVisible({ timeout: 2000 }).catch(() => false); - if (isLoginScreen) { - const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; - await loginInput.fill(apiKey); - await page.locator('button:has-text("Login")').click(); - await page.waitForURL('**/', { timeout: 5000 }); - await waitForNetworkIdle(page); - } - await navigateToContext(page); // Wait for the file input to be attached to the DOM before setting files From 46cb6fa425becd005f31c9242f2a81d9f42e25e9 Mon Sep 17 00:00:00 2001 From: antdev <237216263+yumesha@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:52:57 +0800 Subject: [PATCH 04/17] fixed 'Buffer' is not defined. --- apps/ui/tests/context/add-context-image.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ui/tests/context/add-context-image.spec.ts b/apps/ui/tests/context/add-context-image.spec.ts index 010707ab..2159b42b 100644 --- a/apps/ui/tests/context/add-context-image.spec.ts +++ b/apps/ui/tests/context/add-context-image.spec.ts @@ -5,6 +5,7 @@ */ import { test, expect } from '@playwright/test'; +import { Buffer } from 'buffer'; import * as fs from 'fs'; import * as path from 'path'; import { From ff281e23d0c1ac389e4514c2c92bea8dbbb21b1e Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 02:58:43 -0500 Subject: [PATCH 05/17] feat: implement ideation feature for brainstorming and idea management - Introduced a new IdeationService to manage brainstorming sessions, including idea creation, analysis, and conversion to features. - Added RESTful API routes for ideation, including session management, idea CRUD operations, and suggestion generation. - Created UI components for the ideation dashboard, prompt selection, and category grid to enhance user experience. - Integrated keyboard shortcuts and navigation for the ideation feature, improving accessibility and workflow. - Updated state management with Zustand to handle ideation-specific data and actions. - Added necessary types and paths for ideation functionality, ensuring type safety and clarity in the codebase. --- .claude/.gitignore | 1 + apps/server/src/index.ts | 4 + apps/server/src/providers/claude-provider.ts | 2 +- apps/server/src/routes/ideation/common.ts | 12 + apps/server/src/routes/ideation/index.ts | 99 + .../src/routes/ideation/routes/analyze.ts | 49 + .../src/routes/ideation/routes/convert.ts | 61 + .../routes/ideation/routes/ideas-create.ts | 43 + .../routes/ideation/routes/ideas-delete.ts | 34 + .../src/routes/ideation/routes/ideas-get.ts | 39 + .../src/routes/ideation/routes/ideas-list.ts | 26 + .../routes/ideation/routes/ideas-update.ts | 46 + .../src/routes/ideation/routes/prompts.ts | 42 + .../src/routes/ideation/routes/session-get.ts | 45 + .../routes/ideation/routes/session-message.ts | 40 + .../routes/ideation/routes/session-start.ts | 30 + .../routes/ideation/routes/session-stop.ts | 26 + .../ideation/routes/suggestions-generate.ts | 55 + apps/server/src/services/ideation-service.ts | 1679 +++++++++++++++++ .../layout/sidebar/hooks/use-navigation.ts | 12 + apps/ui/src/components/ui/keyboard-map.tsx | 6 + apps/ui/src/components/views/board-view.tsx | 31 - .../dialogs/feature-suggestions-dialog.tsx | 575 ------ .../views/board-view/dialogs/index.ts | 1 - .../views/board-view/hooks/index.ts | 1 - .../board-view/hooks/use-board-effects.ts | 26 - .../board-view/hooks/use-suggestions-state.ts | 34 - .../views/board-view/kanban-board.tsx | 48 +- .../components/ideation-dashboard.tsx | 340 ++++ .../components/prompt-category-grid.tsx | 78 + .../ideation-view/components/prompt-list.tsx | 162 ++ .../ideation-view/data/guided-prompts.ts | 391 ++++ .../components/views/ideation-view/index.tsx | 208 ++ apps/ui/src/hooks/use-keyboard-shortcuts.ts | 16 +- apps/ui/src/lib/electron.ts | 99 + apps/ui/src/lib/http-api-client.ts | 93 +- apps/ui/src/routes/ideation.tsx | 6 + apps/ui/src/store/app-store.ts | 9 +- apps/ui/src/store/ideation-store.ts | 324 ++++ libs/platform/src/index.ts | 11 + libs/platform/src/paths.ts | 134 ++ libs/types/src/event.ts | 11 +- libs/types/src/ideation.ts | 230 +++ libs/types/src/index.ts | 27 + 44 files changed, 4495 insertions(+), 711 deletions(-) create mode 100644 .claude/.gitignore create mode 100644 apps/server/src/routes/ideation/common.ts create mode 100644 apps/server/src/routes/ideation/index.ts create mode 100644 apps/server/src/routes/ideation/routes/analyze.ts create mode 100644 apps/server/src/routes/ideation/routes/convert.ts create mode 100644 apps/server/src/routes/ideation/routes/ideas-create.ts create mode 100644 apps/server/src/routes/ideation/routes/ideas-delete.ts create mode 100644 apps/server/src/routes/ideation/routes/ideas-get.ts create mode 100644 apps/server/src/routes/ideation/routes/ideas-list.ts create mode 100644 apps/server/src/routes/ideation/routes/ideas-update.ts create mode 100644 apps/server/src/routes/ideation/routes/prompts.ts create mode 100644 apps/server/src/routes/ideation/routes/session-get.ts create mode 100644 apps/server/src/routes/ideation/routes/session-message.ts create mode 100644 apps/server/src/routes/ideation/routes/session-start.ts create mode 100644 apps/server/src/routes/ideation/routes/session-stop.ts create mode 100644 apps/server/src/routes/ideation/routes/suggestions-generate.ts create mode 100644 apps/server/src/services/ideation-service.ts delete mode 100644 apps/ui/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx delete mode 100644 apps/ui/src/components/views/board-view/hooks/use-suggestions-state.ts create mode 100644 apps/ui/src/components/views/ideation-view/components/ideation-dashboard.tsx create mode 100644 apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx create mode 100644 apps/ui/src/components/views/ideation-view/components/prompt-list.tsx create mode 100644 apps/ui/src/components/views/ideation-view/data/guided-prompts.ts create mode 100644 apps/ui/src/components/views/ideation-view/index.tsx create mode 100644 apps/ui/src/routes/ideation.tsx create mode 100644 apps/ui/src/store/ideation-store.ts create mode 100644 libs/types/src/ideation.ts diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 00000000..735e81ff --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1 @@ +hans/ \ No newline at end of file diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0f97255f..ab53a579 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -58,6 +58,8 @@ import { createMCPRoutes } from './routes/mcp/index.js'; import { MCPTestService } from './services/mcp-test-service.js'; import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; +import { createIdeationRoutes } from './routes/ideation/index.js'; +import { IdeationService } from './services/ideation-service.js'; // Load environment variables dotenv.config(); @@ -162,6 +164,7 @@ const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); const claudeUsageService = new ClaudeUsageService(); const mcpTestService = new MCPTestService(settingsService); +const ideationService = new IdeationService(events, settingsService, featureLoader); // Initialize services (async () => { @@ -215,6 +218,7 @@ app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); +app.use('/api/ideation', createIdeationRoutes(ideationService, featureLoader)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..fc380dfc 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -89,7 +89,7 @@ export class ClaudeProvider extends BaseProvider { ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), // When MCP servers are configured and auto-approve is enabled, use bypassPermissions - permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', + permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'acceptEdits', // Required when using bypassPermissions mode ...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }), abortController, diff --git a/apps/server/src/routes/ideation/common.ts b/apps/server/src/routes/ideation/common.ts new file mode 100644 index 00000000..2cca3654 --- /dev/null +++ b/apps/server/src/routes/ideation/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for ideation routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Ideation'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/ideation/index.ts b/apps/server/src/routes/ideation/index.ts new file mode 100644 index 00000000..cd64c739 --- /dev/null +++ b/apps/server/src/routes/ideation/index.ts @@ -0,0 +1,99 @@ +/** + * Ideation routes - HTTP API for brainstorming and idea management + */ + +import { Router } from 'express'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import type { IdeationService } from '../../services/ideation-service.js'; +import type { FeatureLoader } from '../../services/feature-loader.js'; + +// Route handlers +import { createSessionStartHandler } from './routes/session-start.js'; +import { createSessionMessageHandler } from './routes/session-message.js'; +import { createSessionStopHandler } from './routes/session-stop.js'; +import { createSessionGetHandler } from './routes/session-get.js'; +import { createIdeasListHandler } from './routes/ideas-list.js'; +import { createIdeasCreateHandler } from './routes/ideas-create.js'; +import { createIdeasGetHandler } from './routes/ideas-get.js'; +import { createIdeasUpdateHandler } from './routes/ideas-update.js'; +import { createIdeasDeleteHandler } from './routes/ideas-delete.js'; +import { createAnalyzeHandler, createGetAnalysisHandler } from './routes/analyze.js'; +import { createConvertHandler } from './routes/convert.js'; +import { createPromptsHandler, createPromptsByCategoryHandler } from './routes/prompts.js'; +import { createSuggestionsGenerateHandler } from './routes/suggestions-generate.js'; + +export function createIdeationRoutes( + ideationService: IdeationService, + featureLoader: FeatureLoader +): Router { + const router = Router(); + + // Session management + router.post( + '/session/start', + validatePathParams('projectPath'), + createSessionStartHandler(ideationService) + ); + router.post('/session/message', createSessionMessageHandler(ideationService)); + router.post('/session/stop', createSessionStopHandler(ideationService)); + router.post( + '/session/get', + validatePathParams('projectPath'), + createSessionGetHandler(ideationService) + ); + + // Ideas CRUD + router.post( + '/ideas/list', + validatePathParams('projectPath'), + createIdeasListHandler(ideationService) + ); + router.post( + '/ideas/create', + validatePathParams('projectPath'), + createIdeasCreateHandler(ideationService) + ); + router.post( + '/ideas/get', + validatePathParams('projectPath'), + createIdeasGetHandler(ideationService) + ); + router.post( + '/ideas/update', + validatePathParams('projectPath'), + createIdeasUpdateHandler(ideationService) + ); + router.post( + '/ideas/delete', + validatePathParams('projectPath'), + createIdeasDeleteHandler(ideationService) + ); + + // Project analysis + router.post('/analyze', validatePathParams('projectPath'), createAnalyzeHandler(ideationService)); + router.post( + '/analysis', + validatePathParams('projectPath'), + createGetAnalysisHandler(ideationService) + ); + + // Convert to feature + router.post( + '/convert', + validatePathParams('projectPath'), + createConvertHandler(ideationService, featureLoader) + ); + + // Guided prompts (no validation needed - static data) + router.get('/prompts', createPromptsHandler(ideationService)); + router.get('/prompts/:category', createPromptsByCategoryHandler(ideationService)); + + // Generate suggestions (structured output) + router.post( + '/suggestions/generate', + validatePathParams('projectPath'), + createSuggestionsGenerateHandler(ideationService) + ); + + return router; +} diff --git a/apps/server/src/routes/ideation/routes/analyze.ts b/apps/server/src/routes/ideation/routes/analyze.ts new file mode 100644 index 00000000..e8e0b213 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/analyze.ts @@ -0,0 +1,49 @@ +/** + * POST /analyze - Analyze project and generate suggestions + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAnalyzeHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // Start analysis - results come via WebSocket events + ideationService.analyzeProject(projectPath).catch((error) => { + logError(error, 'Analyze project failed (async)'); + }); + + res.json({ success: true, message: 'Analysis started' }); + } catch (error) { + logError(error, 'Analyze project failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createGetAnalysisHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const result = await ideationService.getCachedAnalysis(projectPath); + res.json({ success: true, result }); + } catch (error) { + logError(error, 'Get analysis failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/convert.ts b/apps/server/src/routes/ideation/routes/convert.ts new file mode 100644 index 00000000..ab83164d --- /dev/null +++ b/apps/server/src/routes/ideation/routes/convert.ts @@ -0,0 +1,61 @@ +/** + * POST /convert - Convert an idea to a feature + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { ConvertToFeatureOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createConvertHandler( + ideationService: IdeationService, + featureLoader: FeatureLoader +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId, keepIdea, column, dependencies, tags } = req.body as { + projectPath: string; + ideaId: string; + } & ConvertToFeatureOptions; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + // Convert idea to feature structure + const featureData = await ideationService.convertToFeature(projectPath, ideaId); + + // Apply any options from the request + if (column) { + featureData.status = column; + } + if (dependencies && dependencies.length > 0) { + featureData.dependencies = dependencies; + } + if (tags && tags.length > 0) { + featureData.tags = tags; + } + + // Create the feature using FeatureLoader + const feature = await featureLoader.create(projectPath, featureData); + + // Delete the idea unless keepIdea is explicitly true + if (!keepIdea) { + await ideationService.deleteIdea(projectPath, ideaId); + } + + // Return featureId as expected by the frontend API interface + res.json({ success: true, featureId: feature.id }); + } catch (error) { + logError(error, 'Convert to feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/ideas-create.ts b/apps/server/src/routes/ideation/routes/ideas-create.ts new file mode 100644 index 00000000..d854622e --- /dev/null +++ b/apps/server/src/routes/ideation/routes/ideas-create.ts @@ -0,0 +1,43 @@ +/** + * POST /ideas/create - Create a new idea + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { CreateIdeaInput } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasCreateHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, idea } = req.body as { + projectPath: string; + idea: CreateIdeaInput; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!idea) { + res.status(400).json({ success: false, error: 'idea is required' }); + return; + } + + if (!idea.title || !idea.description || !idea.category) { + res.status(400).json({ + success: false, + error: 'idea must have title, description, and category', + }); + return; + } + + const created = await ideationService.createIdea(projectPath, idea); + res.json({ success: true, idea: created }); + } catch (error) { + logError(error, 'Create idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/ideas-delete.ts b/apps/server/src/routes/ideation/routes/ideas-delete.ts new file mode 100644 index 00000000..931ae32a --- /dev/null +++ b/apps/server/src/routes/ideation/routes/ideas-delete.ts @@ -0,0 +1,34 @@ +/** + * POST /ideas/delete - Delete an idea + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasDeleteHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId } = req.body as { + projectPath: string; + ideaId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + await ideationService.deleteIdea(projectPath, ideaId); + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/ideas-get.ts b/apps/server/src/routes/ideation/routes/ideas-get.ts new file mode 100644 index 00000000..d4865b46 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/ideas-get.ts @@ -0,0 +1,39 @@ +/** + * POST /ideas/get - Get a single idea + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasGetHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId } = req.body as { + projectPath: string; + ideaId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + const idea = await ideationService.getIdea(projectPath, ideaId); + if (!idea) { + res.status(404).json({ success: false, error: 'Idea not found' }); + return; + } + + res.json({ success: true, idea }); + } catch (error) { + logError(error, 'Get idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/ideas-list.ts b/apps/server/src/routes/ideation/routes/ideas-list.ts new file mode 100644 index 00000000..5f6b4504 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/ideas-list.ts @@ -0,0 +1,26 @@ +/** + * POST /ideas/list - List all ideas for a project + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasListHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const ideas = await ideationService.getIdeas(projectPath); + res.json({ success: true, ideas }); + } catch (error) { + logError(error, 'List ideas failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/ideas-update.ts b/apps/server/src/routes/ideation/routes/ideas-update.ts new file mode 100644 index 00000000..c2434ce4 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/ideas-update.ts @@ -0,0 +1,46 @@ +/** + * POST /ideas/update - Update an idea + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { UpdateIdeaInput } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasUpdateHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId, updates } = req.body as { + projectPath: string; + ideaId: string; + updates: UpdateIdeaInput; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + if (!updates) { + res.status(400).json({ success: false, error: 'updates is required' }); + return; + } + + const idea = await ideationService.updateIdea(projectPath, ideaId, updates); + if (!idea) { + res.status(404).json({ success: false, error: 'Idea not found' }); + return; + } + + res.json({ success: true, idea }); + } catch (error) { + logError(error, 'Update idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/prompts.ts b/apps/server/src/routes/ideation/routes/prompts.ts new file mode 100644 index 00000000..fb54e1dd --- /dev/null +++ b/apps/server/src/routes/ideation/routes/prompts.ts @@ -0,0 +1,42 @@ +/** + * GET /prompts - Get all guided prompts + * GET /prompts/:category - Get prompts for a specific category + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { IdeaCategory } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createPromptsHandler(ideationService: IdeationService) { + return async (_req: Request, res: Response): Promise => { + try { + const prompts = ideationService.getAllPrompts(); + const categories = ideationService.getPromptCategories(); + res.json({ success: true, prompts, categories }); + } catch (error) { + logError(error, 'Get prompts failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createPromptsByCategoryHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { category } = req.params as { category: string }; + + const validCategories: IdeaCategory[] = ['feature', 'ux-ui', 'dx', 'growth', 'technical']; + if (!validCategories.includes(category as IdeaCategory)) { + res.status(400).json({ success: false, error: 'Invalid category' }); + return; + } + + const prompts = ideationService.getPromptsByCategory(category as IdeaCategory); + res.json({ success: true, prompts }); + } catch (error) { + logError(error, 'Get prompts by category failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/session-get.ts b/apps/server/src/routes/ideation/routes/session-get.ts new file mode 100644 index 00000000..c95bd6cb --- /dev/null +++ b/apps/server/src/routes/ideation/routes/session-get.ts @@ -0,0 +1,45 @@ +/** + * POST /session/get - Get an ideation session with messages + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionGetHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, sessionId } = req.body as { + projectPath: string; + sessionId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const session = await ideationService.getSession(projectPath, sessionId); + if (!session) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + const isRunning = ideationService.isSessionRunning(sessionId); + + res.json({ + success: true, + session: { ...session, isRunning }, + messages: session.messages, + }); + } catch (error) { + logError(error, 'Get session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/session-message.ts b/apps/server/src/routes/ideation/routes/session-message.ts new file mode 100644 index 00000000..0668583e --- /dev/null +++ b/apps/server/src/routes/ideation/routes/session-message.ts @@ -0,0 +1,40 @@ +/** + * POST /session/message - Send a message in an ideation session + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { SendMessageOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionMessageHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, options } = req.body as { + sessionId: string; + message: string; + options?: SendMessageOptions; + }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + if (!message) { + res.status(400).json({ success: false, error: 'message is required' }); + return; + } + + // This is async but we don't await - responses come via WebSocket + ideationService.sendMessage(sessionId, message, options).catch((error) => { + logError(error, 'Send message failed (async)'); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Send message failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/session-start.ts b/apps/server/src/routes/ideation/routes/session-start.ts new file mode 100644 index 00000000..5d1ae838 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/session-start.ts @@ -0,0 +1,30 @@ +/** + * POST /session/start - Start a new ideation session + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { StartSessionOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionStartHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, options } = req.body as { + projectPath: string; + options?: StartSessionOptions; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const session = await ideationService.startSession(projectPath, options); + res.json({ success: true, session }); + } catch (error) { + logError(error, 'Start session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/session-stop.ts b/apps/server/src/routes/ideation/routes/session-stop.ts new file mode 100644 index 00000000..858d7b7b --- /dev/null +++ b/apps/server/src/routes/ideation/routes/session-stop.ts @@ -0,0 +1,26 @@ +/** + * POST /session/stop - Stop an ideation session + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionStopHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + await ideationService.stopSession(sessionId); + res.json({ success: true }); + } catch (error) { + logError(error, 'Stop session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts new file mode 100644 index 00000000..6907b1af --- /dev/null +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -0,0 +1,55 @@ +/** + * Generate suggestions route - Returns structured AI suggestions for a prompt + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { createLogger } from '@automaker/utils'; + +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; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!promptId) { + res.status(400).json({ success: false, error: 'promptId is required' }); + return; + } + + if (!category) { + res.status(400).json({ success: false, error: 'category is required' }); + return; + } + + // Default to 10 suggestions, allow 1-20 + const suggestionCount = Math.min(Math.max(count || 10, 1), 20); + + logger.info(`Generating ${suggestionCount} suggestions for prompt: ${promptId}`); + + const suggestions = await ideationService.generateSuggestions( + projectPath, + promptId, + category, + suggestionCount + ); + + res.json({ + success: true, + suggestions, + }); + } catch (error) { + logger.error('Failed to generate suggestions:', error); + res.status(500).json({ + success: false, + error: (error as Error).message, + }); + } + }; +} diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts new file mode 100644 index 00000000..d2fde6dd --- /dev/null +++ b/apps/server/src/services/ideation-service.ts @@ -0,0 +1,1679 @@ +/** + * Ideation Service - Manages brainstorming sessions and ideas + * Provides AI-powered ideation, project analysis, and idea-to-feature conversion + */ + +import path from 'path'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { Feature, ExecuteOptions } from '@automaker/types'; +import type { + Idea, + IdeaCategory, + IdeaStatus, + IdeationSession, + IdeationSessionWithMessages, + IdeationMessage, + ProjectAnalysisResult, + AnalysisSuggestion, + AnalysisFileInfo, + CreateIdeaInput, + UpdateIdeaInput, + StartSessionOptions, + SendMessageOptions, + PromptCategory, + IdeationPrompt, +} from '@automaker/types'; +import { + getIdeationDir, + getIdeasDir, + getIdeaDir, + getIdeaPath, + getIdeationSessionsDir, + getIdeationSessionPath, + getIdeationAnalysisPath, + ensureIdeationDir, +} from '@automaker/platform'; +import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import type { SettingsService } from './settings-service.js'; +import type { FeatureLoader } from './feature-loader.js'; +import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; + +const logger = createLogger('IdeationService'); + +interface ActiveSession { + session: IdeationSession; + messages: IdeationMessage[]; + isRunning: boolean; + abortController: AbortController | null; +} + +export class IdeationService { + private activeSessions = new Map(); + private events: EventEmitter; + private settingsService: SettingsService | null = null; + private featureLoader: FeatureLoader | null = null; + + constructor( + events: EventEmitter, + settingsService?: SettingsService, + featureLoader?: FeatureLoader + ) { + this.events = events; + this.settingsService = settingsService ?? null; + this.featureLoader = featureLoader ?? null; + } + + // ============================================================================ + // Session Management + // ============================================================================ + + /** + * Start a new ideation session + */ + async startSession(projectPath: string, options?: StartSessionOptions): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + const sessionId = this.generateId('session'); + const now = new Date().toISOString(); + + const session: IdeationSession = { + id: sessionId, + projectPath, + promptCategory: options?.promptCategory, + promptId: options?.promptId, + status: 'active', + createdAt: now, + updatedAt: now, + }; + + const activeSession: ActiveSession = { + session, + messages: [], + isRunning: false, + abortController: null, + }; + + this.activeSessions.set(sessionId, activeSession); + await this.saveSessionToDisk(projectPath, session, []); + + this.events.emit('ideation:session-started', { sessionId, projectPath }); + + // If there's an initial message from a prompt, send it + if (options?.initialMessage) { + await this.sendMessage(sessionId, options.initialMessage); + } + + return session; + } + + /** + * Get an existing session + */ + async getSession( + projectPath: string, + sessionId: string + ): Promise { + // Check if session is already active in memory + let activeSession = this.activeSessions.get(sessionId); + + if (!activeSession) { + // Try to load from disk + const loaded = await this.loadSessionFromDisk(projectPath, sessionId); + if (!loaded) return null; + + activeSession = { + session: loaded.session, + messages: loaded.messages, + isRunning: false, + abortController: null, + }; + this.activeSessions.set(sessionId, activeSession); + } + + return { + ...activeSession.session, + messages: activeSession.messages, + }; + } + + /** + * Send a message in an ideation session + */ + async sendMessage( + sessionId: string, + message: string, + options?: SendMessageOptions + ): Promise { + const activeSession = this.activeSessions.get(sessionId); + if (!activeSession) { + throw new Error(`Session ${sessionId} not found`); + } + + if (activeSession.isRunning) { + throw new Error('Session is already processing a message'); + } + + activeSession.isRunning = true; + activeSession.abortController = new AbortController(); + + // Add user message + const userMessage: IdeationMessage = { + id: this.generateId('msg'), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + }; + activeSession.messages.push(userMessage); + + // Emit user message + this.events.emit('ideation:stream', { + sessionId, + type: 'message', + message: userMessage, + }); + + try { + const projectPath = activeSession.session.projectPath; + + // Build conversation history + const conversationHistory = activeSession.messages.slice(0, -1).map((msg) => ({ + role: msg.role, + content: msg.content, + })); + + // Load context files + const contextResult = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); + + // Gather existing features and ideas to prevent duplicate suggestions + const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + + // Build system prompt for ideation + const systemPrompt = this.buildIdeationSystemPrompt( + contextResult.formattedPrompt, + activeSession.session.promptCategory, + existingWorkContext + ); + + // Create SDK options + const sdkOptions = createChatOptions({ + cwd: projectPath, + model: options?.model || 'sonnet', + systemPrompt, + abortController: activeSession.abortController!, + }); + + const effectiveModel = sdkOptions.model!; + const provider = ProviderFactory.getProviderForModel(effectiveModel); + + const executeOptions: ExecuteOptions = { + prompt: message, + model: effectiveModel, + cwd: projectPath, + systemPrompt: sdkOptions.systemPrompt, + maxTurns: 1, // Single turn for ideation + abortController: activeSession.abortController!, + conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + }; + + const stream = provider.executeQuery(executeOptions); + + let responseText = ''; + const assistantMessage: IdeationMessage = { + id: this.generateId('msg'), + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + }; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + assistantMessage.content = responseText; + + this.events.emit('ideation:stream', { + sessionId, + type: 'stream', + content: responseText, + done: false, + }); + } + } + } else if (msg.type === 'result') { + if (msg.subtype === 'success' && msg.result) { + assistantMessage.content = msg.result; + responseText = msg.result; + } + } + } + + activeSession.messages.push(assistantMessage); + + this.events.emit('ideation:stream', { + sessionId, + type: 'message-complete', + message: assistantMessage, + content: responseText, + done: true, + }); + + // Save session + await this.saveSessionToDisk(projectPath, activeSession.session, activeSession.messages); + } catch (error) { + if (isAbortError(error)) { + this.events.emit('ideation:stream', { + sessionId, + type: 'aborted', + }); + } else { + logger.error('Error in ideation message:', error); + this.events.emit('ideation:stream', { + sessionId, + type: 'error', + error: (error as Error).message, + }); + } + } finally { + activeSession.isRunning = false; + activeSession.abortController = null; + } + } + + /** + * Stop an active session + */ + async stopSession(sessionId: string): Promise { + const activeSession = this.activeSessions.get(sessionId); + if (!activeSession) return; + + if (activeSession.abortController) { + activeSession.abortController.abort(); + } + + activeSession.isRunning = false; + activeSession.abortController = null; + activeSession.session.status = 'completed'; + + await this.saveSessionToDisk( + activeSession.session.projectPath, + activeSession.session, + activeSession.messages + ); + + this.events.emit('ideation:session-ended', { sessionId }); + } + + // ============================================================================ + // Ideas CRUD + // ============================================================================ + + /** + * Create a new idea + */ + async createIdea(projectPath: string, input: CreateIdeaInput): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + const ideaId = this.generateId('idea'); + const now = new Date().toISOString(); + + const idea: Idea = { + id: ideaId, + title: input.title, + description: input.description, + category: input.category, + status: input.status || 'raw', + impact: input.impact || 'medium', + effort: input.effort || 'medium', + conversationId: input.conversationId, + sourcePromptId: input.sourcePromptId, + userStories: input.userStories, + notes: input.notes, + createdAt: now, + updatedAt: now, + }; + + // Save to disk + const ideaDir = getIdeaDir(projectPath, ideaId); + await secureFs.mkdir(ideaDir, { recursive: true }); + await secureFs.writeFile( + getIdeaPath(projectPath, ideaId), + JSON.stringify(idea, null, 2), + 'utf-8' + ); + + return idea; + } + + /** + * Get all ideas for a project + */ + async getIdeas(projectPath: string): Promise { + try { + const ideasDir = getIdeasDir(projectPath); + + try { + await secureFs.access(ideasDir); + } catch { + return []; + } + + const entries = (await secureFs.readdir(ideasDir, { withFileTypes: true })) as any[]; + const ideaDirs = entries.filter((entry) => entry.isDirectory()); + + const ideas: Idea[] = []; + for (const dir of ideaDirs) { + try { + const ideaPath = getIdeaPath(projectPath, dir.name); + const content = (await secureFs.readFile(ideaPath, 'utf-8')) as string; + ideas.push(JSON.parse(content)); + } catch (error) { + logger.warn(`Failed to load idea ${dir.name}:`, error); + } + } + + // Sort by updatedAt descending + return ideas.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } catch (error) { + logger.error('Failed to get ideas:', error); + return []; + } + } + + /** + * Get a single idea + */ + async getIdea(projectPath: string, ideaId: string): Promise { + try { + const ideaPath = getIdeaPath(projectPath, ideaId); + const content = (await secureFs.readFile(ideaPath, 'utf-8')) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + /** + * Update an idea + */ + async updateIdea( + projectPath: string, + ideaId: string, + updates: UpdateIdeaInput + ): Promise { + const idea = await this.getIdea(projectPath, ideaId); + if (!idea) return null; + + const updatedIdea: Idea = { + ...idea, + ...updates, + updatedAt: new Date().toISOString(), + }; + + await secureFs.writeFile( + getIdeaPath(projectPath, ideaId), + JSON.stringify(updatedIdea, null, 2), + 'utf-8' + ); + + return updatedIdea; + } + + /** + * Delete an idea + */ + async deleteIdea(projectPath: string, ideaId: string): Promise { + const ideaDir = getIdeaDir(projectPath, ideaId); + try { + await secureFs.rm(ideaDir, { recursive: true }); + } catch { + // Ignore if doesn't exist + } + } + + /** + * Archive an idea + */ + async archiveIdea(projectPath: string, ideaId: string): Promise { + return this.updateIdea(projectPath, ideaId, { + status: 'archived' as IdeaStatus, + }); + } + + // ============================================================================ + // Project Analysis + // ============================================================================ + + /** + * Analyze project structure and generate suggestions + */ + async analyzeProject(projectPath: string): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + this.emitAnalysisEvent('ideation:analysis-started', { + projectPath, + message: 'Starting project analysis...', + }); + + try { + // Gather project structure + const structure = await this.gatherProjectStructure(projectPath); + + this.emitAnalysisEvent('ideation:analysis-progress', { + projectPath, + progress: 30, + message: 'Analyzing codebase structure...', + }); + + // Use AI to generate suggestions + const suggestions = await this.generateAnalysisSuggestions(projectPath, structure); + + this.emitAnalysisEvent('ideation:analysis-progress', { + projectPath, + progress: 80, + message: 'Generating improvement suggestions...', + }); + + const result: ProjectAnalysisResult = { + projectPath, + analyzedAt: new Date().toISOString(), + totalFiles: structure.totalFiles, + routes: structure.routes, + components: structure.components, + services: structure.services, + framework: structure.framework, + language: structure.language, + dependencies: structure.dependencies, + suggestions, + summary: this.generateAnalysisSummary(structure, suggestions), + }; + + // Cache the result + await secureFs.writeFile( + getIdeationAnalysisPath(projectPath), + JSON.stringify(result, null, 2), + 'utf-8' + ); + + this.emitAnalysisEvent('ideation:analysis-complete', { + projectPath, + result, + }); + + return result; + } catch (error) { + logger.error('Project analysis failed:', error); + this.emitAnalysisEvent('ideation:analysis-error', { + projectPath, + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Emit analysis event wrapped in ideation:analysis format + */ + private emitAnalysisEvent(eventType: string, data: Record): void { + this.events.emit('ideation:analysis', { + type: eventType, + ...data, + }); + } + + /** + * Check if a session is currently running (processing a message) + */ + isSessionRunning(sessionId: string): boolean { + const activeSession = this.activeSessions.get(sessionId); + return activeSession?.isRunning ?? false; + } + + /** + * Get cached analysis result + */ + async getCachedAnalysis(projectPath: string): Promise { + try { + const content = (await secureFs.readFile( + getIdeationAnalysisPath(projectPath), + 'utf-8' + )) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + // ============================================================================ + // Convert to Feature + // ============================================================================ + + /** + * Convert an idea to a feature + */ + async convertToFeature(projectPath: string, ideaId: string): Promise { + const idea = await this.getIdea(projectPath, ideaId); + if (!idea) { + throw new Error(`Idea ${ideaId} not found`); + } + + // Build feature description from idea + let description = idea.description; + if (idea.userStories && idea.userStories.length > 0) { + description += '\n\n## User Stories\n' + idea.userStories.map((s) => `- ${s}`).join('\n'); + } + if (idea.notes) { + description += '\n\n## Notes\n' + idea.notes; + } + + const feature: Feature = { + id: this.generateId('feature'), + title: idea.title, + category: this.mapIdeaCategoryToFeatureCategory(idea.category), + description, + status: 'backlog', + }; + + return feature; + } + + // ============================================================================ + // Generate Suggestions + // ============================================================================ + + /** + * Generate structured suggestions for a prompt + * Returns parsed suggestions that can be directly added to the board + */ + async generateSuggestions( + projectPath: string, + promptId: string, + category: IdeaCategory, + count: number = 10 + ): Promise { + validateWorkingDirectory(projectPath); + + // Get the prompt + const prompt = this.getAllPrompts().find((p) => p.id === promptId); + if (!prompt) { + throw new Error(`Prompt ${promptId} not found`); + } + + // Emit start event + this.events.emit('ideation:suggestions', { + type: 'started', + promptId, + category, + }); + + try { + // Load context files + const contextResult = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); + + // Build context from multiple sources + let contextPrompt = contextResult.formattedPrompt; + + // If no context files, try to gather basic project info + if (!contextPrompt) { + const projectInfo = await this.gatherBasicProjectInfo(projectPath); + if (projectInfo) { + contextPrompt = projectInfo; + } + } + + // Gather existing features and ideas to prevent duplicates + const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + + // Build system prompt for structured suggestions + const systemPrompt = this.buildSuggestionsSystemPrompt( + contextPrompt, + category, + count, + existingWorkContext + ); + + // Create SDK options + const sdkOptions = createChatOptions({ + cwd: projectPath, + model: 'sonnet', + systemPrompt, + abortController: new AbortController(), + }); + + const effectiveModel = sdkOptions.model!; + const provider = ProviderFactory.getProviderForModel(effectiveModel); + + const executeOptions: ExecuteOptions = { + prompt: prompt.prompt, + model: effectiveModel, + cwd: projectPath, + systemPrompt: sdkOptions.systemPrompt, + maxTurns: 1, + // Disable all tools - we just want text generation, not codebase analysis + allowedTools: [], + abortController: new AbortController(), + }; + + const stream = provider.executeQuery(executeOptions); + + let responseText = ''; + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + responseText = msg.result; + } + } + + // Parse the response into structured suggestions + const suggestions = this.parseSuggestionsFromResponse(responseText, category); + + // Emit complete event + this.events.emit('ideation:suggestions', { + type: 'complete', + promptId, + category, + suggestions, + }); + + return suggestions; + } catch (error) { + logger.error('Failed to generate suggestions:', error); + this.events.emit('ideation:suggestions', { + type: 'error', + promptId, + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Build system prompt for structured suggestion generation + */ + private buildSuggestionsSystemPrompt( + contextFilesPrompt: string | undefined, + category: IdeaCategory, + count: number = 10, + existingWorkContext?: string + ): string { + const contextSection = contextFilesPrompt + ? `## Project Context\n${contextFilesPrompt}` + : `## No Project Context Available\nNo context files were found. Generate suggestions based on the user's prompt and general best practices for the type of application being described.`; + + const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; + + return `You are an AI product strategist helping brainstorm feature ideas for a software project. + +IMPORTANT: You do NOT have access to any tools. You CANNOT read files, search code, or run commands. +You must generate suggestions based ONLY on the project context provided below. +Do NOT say "I'll analyze" or "Let me explore" - you cannot do those things. + +Based on the project context and the user's prompt, generate exactly ${count} creative and actionable feature suggestions. + +YOUR RESPONSE MUST BE ONLY A JSON ARRAY - nothing else. No explanation, no preamble, no markdown code fences. + +Each suggestion must have this structure: +{ + "title": "Short, actionable title (max 60 chars)", + "description": "Clear description of what to build or improve (2-3 sentences)", + "rationale": "Why this is valuable - the problem it solves or opportunity it creates", + "priority": "high" | "medium" | "low" +} + +Focus area: ${this.getCategoryDescription(category)} + +Guidelines: +- Generate exactly ${count} suggestions +- Be specific and actionable - avoid vague ideas +- Mix different priority levels (some high, some medium, some low) +- Each suggestion should be independently implementable +- Think creatively - include both obvious improvements and innovative ideas +- Consider the project's domain and target users +- IMPORTANT: Do NOT suggest features or ideas that already exist in the "Existing Features" or "Existing Ideas" sections below + +${contextSection}${existingWorkSection}`; + } + + /** + * Parse AI response into structured suggestions + */ + private parseSuggestionsFromResponse( + response: string, + category: IdeaCategory + ): 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); + } + + const parsed = JSON.parse(jsonMatch[0]); + if (!Array.isArray(parsed)) { + return this.parseTextResponse(response, category); + } + + 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 || [], + })); + } catch (error) { + logger.warn('Failed to parse JSON response:', error); + return this.parseTextResponse(response, category); + } + } + + /** + * Fallback: parse text response into suggestions + */ + private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] { + const suggestions: AnalysisSuggestion[] = []; + + // Try to find numbered items or headers + const lines = response.split('\n'); + let currentSuggestion: Partial | null = null; + let currentContent: string[] = []; + + for (const line of lines) { + // Check for numbered items or markdown headers + const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/); + + if (titleMatch) { + // Save previous suggestion + if (currentSuggestion && currentSuggestion.title) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: currentSuggestion.title, + description: currentContent.join(' ').trim() || currentSuggestion.title, + rationale: '', + priority: 'medium', + ...currentSuggestion, + } as AnalysisSuggestion); + } + + // Start new suggestion + currentSuggestion = { + title: titleMatch[1].replace(/\*{1,2}/g, '').trim(), + }; + currentContent = []; + } else if (currentSuggestion && line.trim()) { + currentContent.push(line.trim()); + } + } + + // Don't forget the last suggestion + if (currentSuggestion && currentSuggestion.title) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: currentSuggestion.title, + description: currentContent.join(' ').trim() || currentSuggestion.title, + rationale: '', + priority: 'medium', + } as AnalysisSuggestion); + } + + // If no suggestions found, create one from the whole response + if (suggestions.length === 0 && response.trim()) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: 'AI Suggestion', + description: response.slice(0, 500), + rationale: '', + priority: 'medium', + }); + } + + return suggestions.slice(0, 5); // Max 5 suggestions + } + + // ============================================================================ + // Guided Prompts + // ============================================================================ + + /** + * Get all prompt categories + */ + getPromptCategories(): PromptCategory[] { + return [ + { + id: 'feature', + name: 'Features', + icon: 'Zap', + description: 'New capabilities and functionality', + }, + { + id: 'ux-ui', + name: 'UX/UI', + icon: 'Palette', + description: 'Design and user experience improvements', + }, + { + id: 'dx', + name: 'Developer Experience', + icon: 'Code', + description: 'Developer tooling and workflows', + }, + { + id: 'growth', + name: 'Growth', + icon: 'TrendingUp', + description: 'User engagement and retention', + }, + { + id: 'technical', + name: 'Technical', + icon: 'Cpu', + description: 'Architecture and infrastructure', + }, + ]; + } + + /** + * Get prompts for a specific category + */ + getPromptsByCategory(category: IdeaCategory): IdeationPrompt[] { + const allPrompts = this.getAllPrompts(); + return allPrompts.filter((p) => p.category === category); + } + + /** + * Get all guided prompts + * NOTE: Keep in sync with apps/ui/src/components/views/ideation-view/data/guided-prompts.ts + */ + getAllPrompts(): IdeationPrompt[] { + return [ + // Feature prompts + { + id: 'feature-missing', + category: 'feature', + title: 'Missing Features', + description: 'Discover features users might expect', + prompt: + "Based on the project context provided, identify features that users of similar applications typically expect but might be missing. Consider the app's domain, target users, and common patterns in similar products.", + }, + { + id: 'feature-automation', + category: 'feature', + title: 'Automation Opportunities', + description: 'Find manual processes that could be automated', + prompt: + 'Based on the project context, identify manual processes or repetitive tasks that could be automated. Look for patterns where users might be doing things repeatedly that software could handle.', + }, + { + id: 'feature-integrations', + category: 'feature', + title: 'Integration Ideas', + description: 'Identify valuable third-party integrations', + prompt: + "Based on the project context, what third-party services or APIs would provide value if integrated? Consider the app's domain and what complementary services users might need.", + }, + { + id: 'feature-workflow', + category: 'feature', + title: 'Workflow Improvements', + description: 'Streamline user workflows', + prompt: + 'Based on the project context, analyze the user workflows. What steps could be combined, eliminated, or automated? Where are users likely spending too much time on repetitive tasks?', + }, + + // UX/UI prompts + { + id: 'ux-friction', + category: 'ux-ui', + title: 'Friction Points', + description: 'Identify where users might get stuck', + prompt: + 'Based on the project context, identify potential user friction points. Where might users get confused, stuck, or frustrated? Consider form submissions, navigation, error states, and complex interactions.', + }, + { + id: 'ux-empty-states', + category: 'ux-ui', + title: 'Empty States', + description: 'Improve empty state experiences', + prompt: + "Based on the project context, identify empty states that could be improved. How can we guide users when there's no content? Consider onboarding, helpful prompts, and sample data.", + }, + { + id: 'ux-accessibility', + category: 'ux-ui', + title: 'Accessibility Improvements', + description: 'Enhance accessibility and inclusivity', + prompt: + 'Based on the project context, suggest accessibility improvements. Consider keyboard navigation, screen reader support, color contrast, focus states, and ARIA labels. What specific improvements would make this more accessible?', + }, + { + id: 'ux-mobile', + category: 'ux-ui', + title: 'Mobile Experience', + description: 'Optimize for mobile users', + prompt: + 'Based on the project context, suggest improvements for the mobile user experience. Consider touch targets, responsive layouts, and mobile-specific interactions.', + }, + { + id: 'ux-feedback', + category: 'ux-ui', + title: 'User Feedback', + description: 'Improve feedback and status indicators', + prompt: + 'Based on the project context, analyze how the application communicates with users. Where are loading states, success messages, or error handling missing or unclear? What feedback would help users understand what is happening?', + }, + + // DX prompts + { + id: 'dx-documentation', + category: 'dx', + title: 'Documentation Gaps', + description: 'Identify missing documentation', + prompt: + 'Based on the project context, identify areas that could benefit from better documentation. What would help new developers understand the architecture, APIs, and conventions? Consider inline comments, READMEs, and API docs.', + }, + { + id: 'dx-testing', + category: 'dx', + title: 'Testing Improvements', + description: 'Enhance test coverage and quality', + prompt: + 'Based on the project context, suggest areas that need better test coverage. What types of tests might be missing? Consider unit tests, integration tests, and end-to-end tests.', + }, + { + id: 'dx-tooling', + category: 'dx', + title: 'Developer Tooling', + description: 'Improve development workflows', + prompt: + 'Based on the project context, suggest improvements to development workflows. What improvements would speed up development? Consider build times, hot reload, debugging tools, and developer scripts.', + }, + { + id: 'dx-error-handling', + category: 'dx', + title: 'Error Handling', + description: 'Improve error messages and debugging', + prompt: + 'Based on the project context, analyze error handling. Where are error messages unclear or missing? What would help developers debug issues faster? Consider logging, error boundaries, and stack traces.', + }, + + // Growth prompts + { + id: 'growth-onboarding', + category: 'growth', + title: 'Onboarding Flow', + description: 'Improve new user experience', + prompt: + 'Based on the project context, suggest improvements to the onboarding experience. How can we help new users understand the value and get started quickly? Consider tutorials, progressive disclosure, and quick wins.', + }, + { + id: 'growth-engagement', + category: 'growth', + title: 'User Engagement', + description: 'Increase user retention and activity', + prompt: + 'Based on the project context, suggest features that would increase user engagement and retention. What would bring users back daily? Consider notifications, streaks, social features, and personalization.', + }, + { + id: 'growth-sharing', + category: 'growth', + title: 'Shareability', + description: 'Make the app more shareable', + prompt: + 'Based on the project context, suggest ways to make the application more shareable. What features would encourage users to invite others or share their work? Consider collaboration, public profiles, and export features.', + }, + { + id: 'growth-monetization', + category: 'growth', + title: 'Monetization Ideas', + description: 'Identify potential revenue streams', + prompt: + 'Based on the project context, what features or tiers could support monetization? Consider premium features, usage limits, team features, and integrations that users would pay for.', + }, + + // Technical prompts + { + id: 'tech-performance', + category: 'technical', + title: 'Performance Optimization', + description: 'Identify performance bottlenecks', + prompt: + 'Based on the project context, suggest performance optimization opportunities. Where might bottlenecks exist? Consider database queries, API calls, bundle size, rendering, and caching strategies.', + }, + { + id: 'tech-architecture', + category: 'technical', + title: 'Architecture Review', + description: 'Evaluate and improve architecture', + prompt: + 'Based on the project context, suggest architectural improvements. What would make the codebase more maintainable, scalable, or testable? Consider separation of concerns, dependency management, and patterns.', + }, + { + id: 'tech-debt', + category: 'technical', + title: 'Technical Debt', + description: 'Identify areas needing refactoring', + prompt: + 'Based on the project context, identify potential technical debt. What areas might be becoming hard to maintain or understand? What refactoring would have the highest impact? Consider duplicated code, complexity, and outdated patterns.', + }, + { + id: 'tech-security', + category: 'technical', + title: 'Security Review', + description: 'Identify security improvements', + prompt: + 'Based on the project context, review for security improvements. What best practices are missing? Consider authentication, authorization, input validation, and data protection. Note: This is for improvement suggestions, not a security audit.', + }, + + // Security prompts + { + id: 'security-auth', + category: 'security', + title: 'Authentication Security', + description: 'Review authentication mechanisms', + prompt: + 'Based on the project context, analyze the authentication system. What security improvements would strengthen user authentication? Consider password policies, session management, MFA, and token handling.', + }, + { + id: 'security-data', + category: 'security', + title: 'Data Protection', + description: 'Protect sensitive user data', + prompt: + 'Based on the project context, review how sensitive data is handled. What improvements would better protect user privacy? Consider encryption, data minimization, secure storage, and data retention policies.', + }, + { + id: 'security-input', + category: 'security', + title: 'Input Validation', + description: 'Prevent injection attacks', + prompt: + 'Based on the project context, analyze input handling. Where could input validation be strengthened? Consider SQL injection, XSS, command injection, and file upload vulnerabilities.', + }, + { + id: 'security-api', + category: 'security', + title: 'API Security', + description: 'Secure API endpoints', + prompt: + 'Based on the project context, review API security. What improvements would make the API more secure? Consider rate limiting, authorization, CORS, and request validation.', + }, + + // Performance prompts + { + id: 'perf-frontend', + category: 'performance', + title: 'Frontend Performance', + description: 'Optimize UI rendering and loading', + prompt: + 'Based on the project context, analyze frontend performance. What optimizations would improve load times and responsiveness? Consider bundle splitting, lazy loading, memoization, and render optimization.', + }, + { + id: 'perf-backend', + category: 'performance', + title: 'Backend Performance', + description: 'Optimize server-side operations', + prompt: + 'Based on the project context, review backend performance. What optimizations would improve response times? Consider database queries, caching strategies, async operations, and resource pooling.', + }, + { + id: 'perf-database', + category: 'performance', + title: 'Database Optimization', + description: 'Improve query performance', + prompt: + 'Based on the project context, analyze database interactions. What optimizations would improve data access performance? Consider indexing, query optimization, denormalization, and connection pooling.', + }, + { + id: 'perf-caching', + category: 'performance', + title: 'Caching Strategies', + description: 'Implement effective caching', + prompt: + 'Based on the project context, review caching opportunities. Where would caching provide the most benefit? Consider API responses, computed values, static assets, and session data.', + }, + + // Accessibility prompts + { + id: 'a11y-keyboard', + category: 'accessibility', + title: 'Keyboard Navigation', + description: 'Enable full keyboard access', + prompt: + 'Based on the project context, analyze keyboard accessibility. What improvements would enable users to navigate entirely with keyboard? Consider focus management, tab order, and keyboard shortcuts.', + }, + { + id: 'a11y-screen-reader', + category: 'accessibility', + title: 'Screen Reader Support', + description: 'Improve screen reader experience', + prompt: + 'Based on the project context, review screen reader compatibility. What improvements would help users with visual impairments? Consider ARIA labels, semantic HTML, live regions, and alt text.', + }, + { + id: 'a11y-visual', + category: 'accessibility', + title: 'Visual Accessibility', + description: 'Improve visual design for all users', + prompt: + 'Based on the project context, analyze visual accessibility. What improvements would help users with visual impairments? Consider color contrast, text sizing, focus indicators, and reduced motion.', + }, + { + id: 'a11y-forms', + category: 'accessibility', + title: 'Accessible Forms', + description: 'Make forms usable for everyone', + prompt: + 'Based on the project context, review form accessibility. What improvements would make forms more accessible? Consider labels, error messages, required field indicators, and input assistance.', + }, + + // Analytics prompts + { + id: 'analytics-tracking', + category: 'analytics', + title: 'User Tracking', + description: 'Track key user behaviors', + prompt: + 'Based on the project context, analyze analytics opportunities. What user behaviors should be tracked to understand engagement? Consider page views, feature usage, conversion funnels, and session duration.', + }, + { + id: 'analytics-metrics', + category: 'analytics', + title: 'Key Metrics', + description: 'Define success metrics', + prompt: + 'Based on the project context, what key metrics should be tracked? Consider user acquisition, retention, engagement, and feature adoption. What dashboards would be most valuable?', + }, + { + id: 'analytics-errors', + category: 'analytics', + title: 'Error Monitoring', + description: 'Track and analyze errors', + prompt: + 'Based on the project context, review error handling for monitoring opportunities. What error tracking would help identify and fix issues faster? Consider error aggregation, alerting, and stack traces.', + }, + { + id: 'analytics-performance', + category: 'analytics', + title: 'Performance Monitoring', + description: 'Track application performance', + prompt: + 'Based on the project context, analyze performance monitoring opportunities. What metrics would help identify bottlenecks? Consider load times, API response times, and resource usage.', + }, + ]; + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private buildIdeationSystemPrompt( + contextFilesPrompt: string | undefined, + category?: IdeaCategory, + existingWorkContext?: string + ): string { + const basePrompt = `You are an AI product strategist and UX expert helping brainstorm ideas for improving a software project. + +Your role is to: +- Analyze the codebase structure and patterns +- Identify opportunities for improvement +- Suggest actionable ideas with clear rationale +- Consider user experience, technical feasibility, and business value +- Be specific and reference actual files/components when possible + +When suggesting ideas: +1. Provide a clear, concise title +2. Explain the problem or opportunity +3. Describe the proposed solution +4. Highlight the expected benefit +5. Note any dependencies or considerations + +IMPORTANT: Do NOT suggest features or ideas that already exist in the project. Check the "Existing Features" and "Existing Ideas" sections below to avoid duplicates. + +Focus on practical, implementable suggestions that would genuinely improve the product.`; + + const categoryContext = category + ? `\n\nFocus area: ${this.getCategoryDescription(category)}` + : ''; + + const contextSection = contextFilesPrompt + ? `\n\n## Project Context\n${contextFilesPrompt}` + : ''; + + const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; + + return basePrompt + categoryContext + contextSection + existingWorkSection; + } + + private getCategoryDescription(category: IdeaCategory): string { + const descriptions: Record = { + feature: 'New features and capabilities that add value for users', + 'ux-ui': 'User interface and user experience improvements', + dx: 'Developer experience and tooling improvements', + growth: 'User acquisition, engagement, and retention', + technical: 'Architecture, performance, and infrastructure', + security: 'Security improvements and vulnerability fixes', + performance: 'Performance optimization and speed improvements', + accessibility: 'Accessibility features and inclusive design', + analytics: 'Analytics, monitoring, and insights features', + }; + return descriptions[category] || ''; + } + + /** + * Gather basic project information for context when no context files exist + */ + private async gatherBasicProjectInfo(projectPath: string): Promise { + const parts: string[] = []; + + // Try to read package.json + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + const content = (await secureFs.readFile(packageJsonPath, 'utf-8')) as string; + const pkg = JSON.parse(content); + + parts.push('## Project Information (from package.json)'); + if (pkg.name) parts.push(`**Name:** ${pkg.name}`); + if (pkg.description) parts.push(`**Description:** ${pkg.description}`); + if (pkg.version) parts.push(`**Version:** ${pkg.version}`); + + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + const depNames = Object.keys(allDeps); + + // Detect framework and language + let framework = 'Unknown'; + if (allDeps.react) framework = allDeps.next ? 'Next.js' : 'React'; + else if (allDeps.vue) framework = allDeps.nuxt ? 'Nuxt' : 'Vue'; + else if (allDeps['@angular/core']) framework = 'Angular'; + else if (allDeps.svelte) framework = 'Svelte'; + else if (allDeps.express) framework = 'Express'; + else if (allDeps.fastify) framework = 'Fastify'; + else if (allDeps.koa) framework = 'Koa'; + + const language = allDeps.typescript ? 'TypeScript' : 'JavaScript'; + parts.push(`**Tech Stack:** ${framework} with ${language}`); + + // Key dependencies + const keyDeps = depNames + .filter( + (d) => !d.startsWith('@types/') && !['typescript', 'eslint', 'prettier'].includes(d) + ) + .slice(0, 15); + if (keyDeps.length > 0) { + parts.push(`**Key Dependencies:** ${keyDeps.join(', ')}`); + } + + // Scripts + if (pkg.scripts) { + const scriptNames = Object.keys(pkg.scripts).slice(0, 10); + parts.push(`**Available Scripts:** ${scriptNames.join(', ')}`); + } + } catch { + // No package.json, try other files + } + + // Try to read README.md (first 500 chars) + try { + const readmePath = path.join(projectPath, 'README.md'); + const content = (await secureFs.readFile(readmePath, 'utf-8')) as string; + if (content) { + parts.push('\n## README.md (excerpt)'); + parts.push(content.slice(0, 1000)); + } + } catch { + // No README + } + + // Try to get cached analysis + const cachedAnalysis = await this.getCachedAnalysis(projectPath); + if (cachedAnalysis) { + parts.push('\n## Project Structure Analysis'); + parts.push(cachedAnalysis.summary || ''); + if (cachedAnalysis.routes && cachedAnalysis.routes.length > 0) { + parts.push(`**Routes:** ${cachedAnalysis.routes.map((r) => r.name).join(', ')}`); + } + if (cachedAnalysis.components && cachedAnalysis.components.length > 0) { + parts.push( + `**Components:** ${cachedAnalysis.components + .slice(0, 10) + .map((c) => c.name) + .join( + ', ' + )}${cachedAnalysis.components.length > 10 ? ` and ${cachedAnalysis.components.length - 10} more` : ''}` + ); + } + } + + if (parts.length === 0) { + return null; + } + + return parts.join('\n'); + } + + /** + * 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 { + const parts: string[] = []; + + // Load existing features from the board + if (this.featureLoader) { + try { + const features = await this.featureLoader.getAll(projectPath); + if (features.length > 0) { + parts.push('## Existing Features (Do NOT regenerate these)'); + parts.push( + 'The following features already exist on the board. Do NOT suggest similar ideas:\n' + ); + + // Group features by status for clarity + const byStatus: Record = { + done: [], + 'in-review': [], + 'in-progress': [], + backlog: [], + }; + + for (const feature of features) { + const status = feature.status || 'backlog'; + const title = feature.title || 'Untitled'; + if (byStatus[status]) { + byStatus[status].push(title); + } else { + byStatus['backlog'].push(title); + } + } + + // Output completed features first (most important to not duplicate) + if (byStatus['done'].length > 0) { + parts.push(`**Completed:** ${byStatus['done'].join(', ')}`); + } + if (byStatus['in-review'].length > 0) { + parts.push(`**In Review:** ${byStatus['in-review'].join(', ')}`); + } + if (byStatus['in-progress'].length > 0) { + parts.push(`**In Progress:** ${byStatus['in-progress'].join(', ')}`); + } + if (byStatus['backlog'].length > 0) { + parts.push(`**Backlog:** ${byStatus['backlog'].join(', ')}`); + } + parts.push(''); + } + } catch (error) { + logger.warn('Failed to load existing features:', error); + } + } + + // Load existing ideas + 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' + ); + + // 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); + } + + for (const [category, titles] of Object.entries(byCategory)) { + parts.push(`**${category}:** ${titles.join(', ')}`); + } + parts.push(''); + } + } catch (error) { + logger.warn('Failed to load existing ideas:', error); + } + + return parts.join('\n'); + } + + private async gatherProjectStructure(projectPath: string): Promise<{ + totalFiles: number; + routes: AnalysisFileInfo[]; + components: AnalysisFileInfo[]; + services: AnalysisFileInfo[]; + framework?: string; + language?: string; + dependencies?: string[]; + }> { + const routes: AnalysisFileInfo[] = []; + const components: AnalysisFileInfo[] = []; + const services: AnalysisFileInfo[] = []; + let totalFiles = 0; + let framework: string | undefined; + let language: string | undefined; + const dependencies: string[] = []; + + // Check for package.json to detect framework and dependencies + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + const content = (await secureFs.readFile(packageJsonPath, 'utf-8')) as string; + const pkg = JSON.parse(content); + + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + dependencies.push(...Object.keys(allDeps).slice(0, 20)); // Top 20 deps + + if (allDeps.react) framework = 'React'; + else if (allDeps.vue) framework = 'Vue'; + else if (allDeps.angular) framework = 'Angular'; + else if (allDeps.next) framework = 'Next.js'; + else if (allDeps.express) framework = 'Express'; + + language = allDeps.typescript ? 'TypeScript' : 'JavaScript'; + } catch { + // No package.json + } + + // Scan common directories + const scanPatterns = [ + { dir: 'src/routes', type: 'route' as const }, + { dir: 'src/pages', type: 'route' as const }, + { dir: 'app', type: 'route' as const }, + { dir: 'src/components', type: 'component' as const }, + { dir: 'components', type: 'component' as const }, + { dir: 'src/services', type: 'service' as const }, + { dir: 'src/lib', type: 'service' as const }, + { dir: 'lib', type: 'service' as const }, + ]; + + for (const pattern of scanPatterns) { + const fullPath = path.join(projectPath, pattern.dir); + try { + const files = await this.scanDirectory(fullPath, pattern.type); + totalFiles += files.length; + + if (pattern.type === 'route') routes.push(...files); + else if (pattern.type === 'component') components.push(...files); + else if (pattern.type === 'service') services.push(...files); + } catch { + // Directory doesn't exist + } + } + + return { + totalFiles, + routes: routes.slice(0, 20), + components: components.slice(0, 30), + services: services.slice(0, 20), + framework, + language, + dependencies, + }; + } + + private async scanDirectory( + dirPath: string, + type: 'route' | 'component' | 'service' | 'model' | 'config' | 'test' | 'other' + ): Promise { + const results: AnalysisFileInfo[] = []; + + try { + const entries = (await secureFs.readdir(dirPath, { withFileTypes: true })) as any[]; + + for (const entry of entries) { + if (entry.isDirectory()) { + const subResults = await this.scanDirectory(path.join(dirPath, entry.name), type); + results.push(...subResults); + } else if (entry.isFile() && this.isCodeFile(entry.name)) { + results.push({ + path: path.join(dirPath, entry.name), + type, + name: entry.name.replace(/\.(tsx?|jsx?|vue)$/, ''), + }); + } + } + } catch { + // Ignore errors + } + + return results; + } + + private isCodeFile(filename: string): boolean { + return ( + /\.(tsx?|jsx?|vue|svelte)$/.test(filename) && + !filename.includes('.test.') && + !filename.includes('.spec.') + ); + } + + private async generateAnalysisSuggestions( + _projectPath: string, + structure: Awaited> + ): Promise { + // Generate basic suggestions based on project structure analysis + const suggestions: AnalysisSuggestion[] = []; + + if (structure.routes.length > 0 && structure.routes.length < 5) { + suggestions.push({ + id: this.generateId('sug'), + category: 'feature', + title: 'Expand Core Functionality', + description: 'The app has a small number of routes. Consider adding more features.', + rationale: `Only ${structure.routes.length} routes detected. Most apps benefit from additional navigation options.`, + priority: 'medium', + }); + } + + if ( + !structure.dependencies?.includes('react-query') && + !structure.dependencies?.includes('@tanstack/react-query') + ) { + suggestions.push({ + id: this.generateId('sug'), + category: 'technical', + title: 'Add Data Fetching Library', + description: 'Consider adding React Query or similar for better data management.', + rationale: + 'Data fetching libraries provide caching, background updates, and better loading states.', + priority: 'low', + }); + } + + return suggestions; + } + + private generateAnalysisSummary( + structure: Awaited>, + suggestions: AnalysisSuggestion[] + ): string { + const parts: string[] = []; + + if (structure.framework) { + parts.push(`${structure.framework} ${structure.language || ''} application`); + } + + parts.push(`with ${structure.totalFiles} code files`); + parts.push(`${structure.routes.length} routes`); + parts.push(`${structure.components.length} components`); + parts.push(`${structure.services.length} services`); + + const summary = parts.join(', '); + const highPriority = suggestions.filter((s) => s.priority === 'high').length; + + return `${summary}. Found ${suggestions.length} improvement opportunities${highPriority > 0 ? ` (${highPriority} high priority)` : ''}.`; + } + + private mapIdeaCategoryToFeatureCategory(category: IdeaCategory): string { + const mapping: Record = { + feature: 'feature', + 'ux-ui': 'enhancement', + dx: 'chore', + growth: 'feature', + technical: 'refactor', + security: 'bug', + performance: 'enhancement', + accessibility: 'enhancement', + analytics: 'feature', + }; + return mapping[category] || 'feature'; + } + + private async saveSessionToDisk( + projectPath: string, + session: IdeationSession, + messages: IdeationMessage[] + ): Promise { + await secureFs.mkdir(getIdeationSessionsDir(projectPath), { recursive: true }); + const data = { session, messages }; + await secureFs.writeFile( + getIdeationSessionPath(projectPath, session.id), + JSON.stringify(data, null, 2), + 'utf-8' + ); + } + + private async loadSessionFromDisk( + projectPath: string, + sessionId: string + ): Promise<{ session: IdeationSession; messages: IdeationMessage[] } | null> { + try { + const content = (await secureFs.readFile( + getIdeationSessionPath(projectPath, sessionId), + 'utf-8' + )) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + private generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index de4e19f8..314765e0 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -10,6 +10,7 @@ import { CircleDot, GitPullRequest, Zap, + Lightbulb, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -30,6 +31,9 @@ interface UseNavigationProps { agent: string; terminal: string; settings: string; + ideation: string; + githubIssues: string; + githubPrs: string; }; hideSpecEditor: boolean; hideContext: boolean; @@ -92,6 +96,12 @@ export function useNavigation({ // Build navigation sections const navSections: NavSection[] = useMemo(() => { const allToolsItems: NavItem[] = [ + { + id: 'ideation', + label: 'Ideation', + icon: Lightbulb, + shortcut: shortcuts.ideation, + }, { id: 'spec', label: 'Spec Editor', @@ -172,12 +182,14 @@ export function useNavigation({ id: 'github-issues', label: 'Issues', icon: CircleDot, + shortcut: shortcuts.githubIssues, count: unviewedValidationsCount, }, { id: 'github-prs', label: 'Pull Requests', icon: GitPullRequest, + shortcut: shortcuts.githubPrs, }, ], }); diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index 8f235d55..2e00c1e2 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -90,6 +90,9 @@ const SHORTCUT_LABELS: Record = { settings: 'Settings', profiles: 'AI Profiles', terminal: 'Terminal', + ideation: 'Ideation', + githubIssues: 'GitHub Issues', + githubPrs: 'Pull Requests', toggleSidebar: 'Toggle Sidebar', addFeature: 'Add Feature', addContextFile: 'Add Context File', @@ -115,6 +118,9 @@ const SHORTCUT_CATEGORIES: Record setShowSuggestionsDialog(true)} - suggestionsCount={suggestionsCount} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} pipelineConfig={ currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null @@ -1269,17 +1249,6 @@ export function BoardView() { isMaximized={isMaximized} /> - {/* Feature Suggestions Dialog */} - - {/* Backlog Plan Dialog */} void; - projectPath: string; - // Props to persist state across dialog open/close - suggestions: FeatureSuggestion[]; - setSuggestions: (suggestions: FeatureSuggestion[]) => void; - isGenerating: boolean; - setIsGenerating: (generating: boolean) => void; -} - -// Configuration for each suggestion type -const suggestionTypeConfig: Record< - SuggestionType, - { - label: string; - icon: React.ComponentType<{ className?: string }>; - description: string; - color: string; - } -> = { - features: { - label: 'Feature Suggestions', - icon: Lightbulb, - description: 'Discover missing features and improvements', - color: 'text-yellow-500', - }, - refactoring: { - label: 'Refactoring Suggestions', - icon: RefreshCw, - description: 'Find code smells and refactoring opportunities', - color: 'text-blue-500', - }, - security: { - label: 'Security Suggestions', - icon: Shield, - description: 'Identify security vulnerabilities and issues', - color: 'text-red-500', - }, - performance: { - label: 'Performance Suggestions', - icon: Zap, - description: 'Discover performance bottlenecks and optimizations', - color: 'text-green-500', - }, -}; - -export function FeatureSuggestionsDialog({ - open, - onClose, - projectPath, - suggestions, - setSuggestions, - isGenerating, - setIsGenerating, -}: FeatureSuggestionsDialogProps) { - const [progress, setProgress] = useState([]); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [expandedIds, setExpandedIds] = useState>(new Set()); - const [isImporting, setIsImporting] = useState(false); - const [currentSuggestionType, setCurrentSuggestionType] = useState(null); - const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed'); - const scrollRef = useRef(null); - const autoScrollRef = useRef(true); - - const { features, setFeatures } = useAppStore(); - - // Initialize selectedIds when suggestions change - useEffect(() => { - if (suggestions.length > 0 && selectedIds.size === 0) { - setSelectedIds(new Set(suggestions.map((s) => s.id))); - } - }, [suggestions, selectedIds.size]); - - // Auto-scroll progress when new content arrives - useEffect(() => { - if (autoScrollRef.current && scrollRef.current && isGenerating) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [progress, isGenerating]); - - // Listen for suggestion events when dialog is open - useEffect(() => { - if (!open) return; - - const api = getElectronAPI(); - if (!api?.suggestions) return; - - const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => { - if (event.type === 'suggestions_progress') { - setProgress((prev) => [...prev, event.content || '']); - } else if (event.type === 'suggestions_tool') { - const toolName = event.tool || 'Unknown Tool'; - const toolInput = event.input ? JSON.stringify(event.input, null, 2) : ''; - const formattedTool = `\nšŸ”§ Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`; - setProgress((prev) => [...prev, formattedTool]); - } else if (event.type === 'suggestions_complete') { - setIsGenerating(false); - if (event.suggestions && event.suggestions.length > 0) { - setSuggestions(event.suggestions); - // Select all by default - setSelectedIds(new Set(event.suggestions.map((s) => s.id))); - const typeLabel = currentSuggestionType - ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() - : 'suggestions'; - toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`); - } else { - toast.info('No suggestions generated. Try again.'); - } - } else if (event.type === 'suggestions_error') { - setIsGenerating(false); - toast.error(`Error: ${event.error}`); - } - }); - - return () => { - unsubscribe(); - }; - }, [open, setSuggestions, setIsGenerating, currentSuggestionType]); - - // Start generating suggestions for a specific type - const handleGenerate = useCallback( - async (suggestionType: SuggestionType) => { - const api = getElectronAPI(); - if (!api?.suggestions) { - toast.error('Suggestions API not available'); - return; - } - - setIsGenerating(true); - setProgress([]); - setSuggestions([]); - setSelectedIds(new Set()); - setCurrentSuggestionType(suggestionType); - - try { - const result = await api.suggestions.generate(projectPath, suggestionType); - if (!result.success) { - toast.error(result.error || 'Failed to start generation'); - setIsGenerating(false); - } - } catch (error) { - console.error('Failed to generate suggestions:', error); - toast.error('Failed to start generation'); - setIsGenerating(false); - } - }, - [projectPath, setIsGenerating, setSuggestions] - ); - - // Stop generating - const handleStop = useCallback(async () => { - const api = getElectronAPI(); - if (!api?.suggestions) return; - - try { - await api.suggestions.stop(); - setIsGenerating(false); - toast.info('Generation stopped'); - } catch (error) { - console.error('Failed to stop generation:', error); - } - }, [setIsGenerating]); - - // Toggle suggestion selection - const toggleSelection = useCallback((id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - // Toggle expand/collapse for a suggestion - const toggleExpanded = useCallback((id: string) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - // Select/deselect all - const toggleSelectAll = useCallback(() => { - if (selectedIds.size === suggestions.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(suggestions.map((s) => s.id))); - } - }, [selectedIds.size, suggestions]); - - // Import selected suggestions as features - const handleImport = useCallback(async () => { - if (selectedIds.size === 0) { - toast.warning('No suggestions selected'); - return; - } - - setIsImporting(true); - - try { - const api = getElectronAPI(); - const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id)); - - // Create new features from selected suggestions - const newFeatures: Feature[] = selectedSuggestions.map((s) => ({ - id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - category: s.category, - description: s.description, - steps: [], // Required empty steps array for new features - status: 'backlog' as const, - skipTests: true, // As specified, testing mode true - priority: s.priority, // Preserve priority from suggestion - })); - - // Create each new feature using the features API - if (api.features) { - for (const feature of newFeatures) { - await api.features.create(projectPath, feature); - } - } - - // Merge with existing features for store update - const updatedFeatures = [...features, ...newFeatures]; - - // Update store - setFeatures(updatedFeatures); - - toast.success(`Imported ${newFeatures.length} features to backlog!`); - - // Clear suggestions after importing - setSuggestions([]); - setSelectedIds(new Set()); - setProgress([]); - setCurrentSuggestionType(null); - - onClose(); - } catch (error) { - console.error('Failed to import features:', error); - toast.error('Failed to import features'); - } finally { - setIsImporting(false); - } - }, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]); - - // Handle scroll to detect if user scrolled up - const handleScroll = () => { - if (!scrollRef.current) return; - - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; - autoScrollRef.current = isAtBottom; - }; - - // Go back to type selection - const handleBackToSelection = useCallback(() => { - setSuggestions([]); - setSelectedIds(new Set()); - setProgress([]); - setCurrentSuggestionType(null); - }, [setSuggestions]); - - const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0; - const hasSuggestions = suggestions.length > 0; - const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null; - - return ( - - - - - {currentConfig ? ( - <> - - {currentConfig.label} - - ) : ( - <> - - AI Suggestions - - )} - - - {currentConfig - ? currentConfig.description - : 'Analyze your project to discover improvements. Choose a suggestion type below.'} - - - - {!hasStarted ? ( - // Initial state - show suggestion type buttons -
-

- Our AI will analyze your project and generate actionable suggestions. Choose what type - of analysis you want to perform: -

-
- {( - Object.entries(suggestionTypeConfig) as [ - SuggestionType, - (typeof suggestionTypeConfig)[SuggestionType], - ][] - ).map(([type, config]) => { - const Icon = config.icon; - return ( - - ); - })} -
-
- ) : isGenerating ? ( - // Generating state - show progress -
-
-
- - Analyzing project... -
-
-
- - -
- -
-
-
- {progress.length === 0 ? ( -
- - Waiting for AI response... -
- ) : viewMode === 'parsed' ? ( - - ) : ( -
- {progress.join('')} -
- )} -
-
- ) : hasSuggestions ? ( - // Results state - show suggestions list -
-
-
- - {suggestions.length} suggestions generated - - -
- {selectedIds.size} selected -
- -
- {suggestions.map((suggestion) => { - const isSelected = selectedIds.has(suggestion.id); - const isExpanded = expandedIds.has(suggestion.id); - - return ( -
-
- toggleSelection(suggestion.id)} - className="mt-1" - /> -
-
- - - #{suggestion.priority} - - - {suggestion.category} - -
- - - {isExpanded && suggestion.reasoning && ( -
-

{suggestion.reasoning}

-
- )} -
-
-
- ); - })} -
-
- ) : ( - // No results state -
-

- No suggestions were generated. Try running the analysis again. -

-
- - {currentSuggestionType && ( - - )} -
-
- )} - - - {hasSuggestions && ( -
-
- - {currentSuggestionType && ( - - )} -
-
- - - {isImporting ? ( - - ) : ( - - )} - Import {selectedIds.size} Feature - {selectedIds.size !== 1 ? 's' : ''} - -
-
- )} - {!hasSuggestions && !isGenerating && hasStarted && ( - - )} -
-
-
- ); -} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 30f1df7e..6979f9d4 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -5,6 +5,5 @@ export { CompletedFeaturesModal } from './completed-features-modal'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; export { EditFeatureDialog } from './edit-feature-dialog'; -export { FeatureSuggestionsDialog } from './feature-suggestions-dialog'; export { FollowUpDialog } from './follow-up-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog'; diff --git a/apps/ui/src/components/views/board-view/hooks/index.ts b/apps/ui/src/components/views/board-view/hooks/index.ts index a1577e07..9b855b06 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -7,4 +7,3 @@ export { useBoardEffects } from './use-board-effects'; export { useBoardBackground } from './use-board-background'; export { useBoardPersistence } from './use-board-persistence'; export { useFollowUpState } from './use-follow-up-state'; -export { useSuggestionsState } from './use-suggestions-state'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts index 318b326b..0603af3b 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts @@ -6,9 +6,6 @@ interface UseBoardEffectsProps { currentProject: { path: string; id: string } | null; specCreatingForProject: string | null; setSpecCreatingForProject: (path: string | null) => void; - setSuggestionsCount: (count: number) => void; - setFeatureSuggestions: (suggestions: any[]) => void; - setIsGeneratingSuggestions: (generating: boolean) => void; checkContextExists: (featureId: string) => Promise; features: any[]; isLoading: boolean; @@ -20,9 +17,6 @@ export function useBoardEffects({ currentProject, specCreatingForProject, setSpecCreatingForProject, - setSuggestionsCount, - setFeatureSuggestions, - setIsGeneratingSuggestions, checkContextExists, features, isLoading, @@ -44,26 +38,6 @@ export function useBoardEffects({ }; }, [currentProject]); - // Listen for suggestions events to update count (persists even when dialog is closed) - useEffect(() => { - const api = getElectronAPI(); - if (!api?.suggestions) return; - - const unsubscribe = api.suggestions.onEvent((event) => { - if (event.type === 'suggestions_complete' && event.suggestions) { - setSuggestionsCount(event.suggestions.length); - setFeatureSuggestions(event.suggestions); - setIsGeneratingSuggestions(false); - } else if (event.type === 'suggestions_error') { - setIsGeneratingSuggestions(false); - } - }); - - return () => { - unsubscribe(); - }; - }, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]); - // Subscribe to spec regeneration events to clear creating state on completion useEffect(() => { const api = getElectronAPI(); diff --git a/apps/ui/src/components/views/board-view/hooks/use-suggestions-state.ts b/apps/ui/src/components/views/board-view/hooks/use-suggestions-state.ts deleted file mode 100644 index 25e3cd14..00000000 --- a/apps/ui/src/components/views/board-view/hooks/use-suggestions-state.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { FeatureSuggestion } from '@/lib/electron'; - -export function useSuggestionsState() { - const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false); - const [suggestionsCount, setSuggestionsCount] = useState(0); - const [featureSuggestions, setFeatureSuggestions] = useState([]); - const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false); - - const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => { - setFeatureSuggestions(suggestions); - setSuggestionsCount(suggestions.length); - }, []); - - const closeSuggestionsDialog = useCallback(() => { - setShowSuggestionsDialog(false); - }, []); - - return { - // State - showSuggestionsDialog, - suggestionsCount, - featureSuggestions, - isGeneratingSuggestions, - // Setters - setShowSuggestionsDialog, - setSuggestionsCount, - setFeatureSuggestions, - setIsGeneratingSuggestions, - // Helpers - updateSuggestions, - closeSuggestionsDialog, - }; -} diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index eecadc61..c21711b9 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { HotkeyButton } from '@/components/ui/hotkey-button'; import { KanbanColumn, KanbanCard } from './components'; import { Feature } from '@/store/app-store'; -import { FastForward, Lightbulb, Archive, Plus, Settings2 } from 'lucide-react'; +import { FastForward, Archive, Plus, Settings2 } from 'lucide-react'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useResponsiveKanban } from '@/hooks/use-responsive-kanban'; import { getColumnsWithPipeline, type Column, type ColumnId } from './constants'; @@ -47,8 +47,6 @@ interface KanbanBoardProps { runningAutoTasks: string[]; shortcuts: ReturnType; onStartNextFeatures: () => void; - onShowSuggestions: () => void; - suggestionsCount: number; onArchiveAllVerified: () => void; pipelineConfig: PipelineConfig | null; onOpenPipelineSettings?: () => void; @@ -82,8 +80,6 @@ export function KanbanBoard({ runningAutoTasks, shortcuts, onStartNextFeatures, - onShowSuggestions, - suggestionsCount, onArchiveAllVerified, pipelineConfig, onOpenPipelineSettings, @@ -130,40 +126,20 @@ export function KanbanBoard({ Complete All ) : column.id === 'backlog' ? ( -
- - {columnFeatures.length > 0 && ( - - - Make - - )} -
+ + Make + + ) ) : column.id === 'in_progress' ? ( + + + + + + ); +} + +function GeneratingCard({ job }: { job: GenerationJob }) { + const { removeJob } = useIdeationStore(); + const isError = job.status === 'error'; + + return ( + + +
+
+ {isError ? ( + + ) : ( + + )} +
+

{job.prompt.title}

+

+ {isError ? job.error || 'Failed to generate' : 'Generating ideas...'} +

+
+
+ +
+
+
+ ); +} + +function TagFilter({ + tags, + tagCounts, + selectedTags, + onToggleTag, +}: { + tags: string[]; + tagCounts: Record; + selectedTags: Set; + onToggleTag: (tag: string) => void; +}) { + if (tags.length === 0) return null; + + return ( +
+ {tags.map((tag) => { + const isSelected = selectedTags.has(tag); + const count = tagCounts[tag] || 0; + return ( + + ); + })} + {selectedTags.size > 0 && ( + + )} +
+ ); +} + +export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) { + const currentProject = useAppStore((s) => s.currentProject); + const { generationJobs, removeSuggestionFromJob } = useIdeationStore(); + const [addingId, setAddingId] = useState(null); + const [selectedTags, setSelectedTags] = useState>(new Set()); + + // Separate generating/error jobs from ready jobs with suggestions + const activeJobs = generationJobs.filter( + (j) => j.status === 'generating' || j.status === 'error' + ); + const readyJobs = generationJobs.filter((j) => j.status === 'ready' && j.suggestions.length > 0); + + // Flatten all suggestions with their parent job + const allSuggestions = useMemo( + () => readyJobs.flatMap((job) => job.suggestions.map((suggestion) => ({ suggestion, job }))), + [readyJobs] + ); + + // Extract unique tags and counts from all suggestions + const { availableTags, tagCounts } = useMemo(() => { + const counts: Record = {}; + allSuggestions.forEach(({ job }) => { + const tag = job.prompt.title; + counts[tag] = (counts[tag] || 0) + 1; + }); + return { + availableTags: Object.keys(counts).sort(), + tagCounts: counts, + }; + }, [allSuggestions]); + + // Filter suggestions based on selected tags + const filteredSuggestions = useMemo(() => { + if (selectedTags.size === 0) return allSuggestions; + return allSuggestions.filter(({ job }) => selectedTags.has(job.prompt.title)); + }, [allSuggestions, selectedTags]); + + const generatingCount = generationJobs.filter((j) => j.status === 'generating').length; + + const handleToggleTag = (tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) { + next.delete(tag); + } else { + next.add(tag); + } + return next; + }); + }; + + const handleAccept = async (suggestion: AnalysisSuggestion, jobId: string) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + setAddingId(suggestion.id); + + try { + const api = getElectronAPI(); + const result = await api.ideation?.addSuggestionToBoard(currentProject.path, suggestion); + + if (result?.success) { + toast.success(`Added "${suggestion.title}" to board`); + removeSuggestionFromJob(jobId, suggestion.id); + } else { + toast.error(result?.error || 'Failed to add to board'); + } + } catch (error) { + console.error('Failed to add to board:', error); + toast.error((error as Error).message); + } finally { + setAddingId(null); + } + }; + + const handleRemove = (suggestionId: string, jobId: string) => { + removeSuggestionFromJob(jobId, suggestionId); + toast.info('Idea removed'); + }; + + const isEmpty = allSuggestions.length === 0 && activeJobs.length === 0; + + return ( +
+
+ {/* Status text */} + {(generatingCount > 0 || allSuggestions.length > 0) && ( +

+ {generatingCount > 0 + ? `Generating ${generatingCount} idea${generatingCount > 1 ? 's' : ''}...` + : selectedTags.size > 0 + ? `Showing ${filteredSuggestions.length} of ${allSuggestions.length} ideas` + : `${allSuggestions.length} idea${allSuggestions.length > 1 ? 's' : ''} ready for review`} +

+ )} + + {/* Tag Filters */} + {availableTags.length > 0 && ( + + )} + + {/* Generating/Error Jobs */} + {activeJobs.length > 0 && ( +
+ {activeJobs.map((job) => ( + + ))} +
+ )} + + {/* Suggestions List */} + {filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map(({ suggestion, job }) => ( + handleAccept(suggestion, job.id)} + onRemove={() => handleRemove(suggestion.id, job.id)} + isAdding={addingId === suggestion.id} + /> + ))} +
+ )} + + {/* No results after filtering */} + {filteredSuggestions.length === 0 && allSuggestions.length > 0 && ( + + +
+

No ideas match the selected filters

+ +
+
+
+ )} + + {/* Empty State */} + {isEmpty && ( + + +
+ +

No ideas yet

+

+ Generate ideas by selecting a category and prompt type +

+ +
+
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx new file mode 100644 index 00000000..abf29c83 --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx @@ -0,0 +1,78 @@ +/** + * PromptCategoryGrid - Grid of prompt categories to select from + */ + +import { + ArrowLeft, + Zap, + Palette, + Code, + TrendingUp, + Cpu, + Shield, + Gauge, + Accessibility, + BarChart3, +} from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { PROMPT_CATEGORIES } from '../data/guided-prompts'; +import type { IdeaCategory } from '@automaker/types'; + +interface PromptCategoryGridProps { + onSelect: (category: IdeaCategory) => void; + onBack: () => void; +} + +const iconMap: Record = { + Zap, + Palette, + Code, + TrendingUp, + Cpu, + Shield, + Gauge, + Accessibility, + BarChart3, +}; + +export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps) { + return ( +
+
+ {/* Back link */} + + +
+ {PROMPT_CATEGORIES.map((category) => { + const Icon = iconMap[category.icon] || Zap; + return ( + onSelect(category.id)} + > + +
+
+ +
+
+

{category.name}

+

{category.description}

+
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx new file mode 100644 index 00000000..b9fd1d31 --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -0,0 +1,162 @@ +/** + * PromptList - List of prompts for a specific category + */ + +import { useState } from 'react'; +import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { getPromptsByCategory } from '../data/guided-prompts'; +import { useIdeationStore } from '@/store/ideation-store'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { toast } from 'sonner'; +import { useNavigate } from '@tanstack/react-router'; +import type { IdeaCategory, IdeationPrompt } from '@automaker/types'; + +interface PromptListProps { + category: IdeaCategory; + onBack: () => void; +} + +export function PromptList({ category, onBack }: PromptListProps) { + const currentProject = useAppStore((s) => s.currentProject); + const { setMode, addGenerationJob, updateJobStatus, generationJobs } = useIdeationStore(); + const [loadingPromptId, setLoadingPromptId] = useState(null); + const [startedPrompts, setStartedPrompts] = useState>(new Set()); + const navigate = useNavigate(); + + const prompts = getPromptsByCategory(category); + + // Check which prompts are already generating + const generatingPromptIds = new Set( + generationJobs.filter((j) => j.status === 'generating').map((j) => j.prompt.id) + ); + + const handleSelectPrompt = async (prompt: IdeationPrompt) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + if (loadingPromptId || generatingPromptIds.has(prompt.id)) return; + + setLoadingPromptId(prompt.id); + + // Add a job and navigate to dashboard + const jobId = addGenerationJob(prompt); + setStartedPrompts((prev) => new Set(prev).add(prompt.id)); + + // Show toast and navigate to dashboard + toast.info(`Generating ideas for "${prompt.title}"...`); + setMode('dashboard'); + + try { + const api = getElectronAPI(); + const result = await api.ideation?.generateSuggestions( + currentProject.path, + prompt.id, + category + ); + + if (result?.success && result.suggestions) { + updateJobStatus(jobId, 'ready', result.suggestions); + toast.success(`Generated ${result.suggestions.length} ideas for "${prompt.title}"`, { + duration: 10000, + action: { + label: 'View Ideas', + onClick: () => { + setMode('dashboard'); + navigate({ to: '/ideation' }); + }, + }, + }); + } else { + updateJobStatus( + jobId, + 'error', + undefined, + result?.error || 'Failed to generate suggestions' + ); + toast.error(result?.error || 'Failed to generate suggestions'); + } + } catch (error) { + console.error('Failed to generate suggestions:', error); + updateJobStatus(jobId, 'error', undefined, (error as Error).message); + toast.error((error as Error).message); + } finally { + setLoadingPromptId(null); + } + }; + + return ( +
+
+ {/* Back link */} + + +
+ {prompts.map((prompt) => { + const isLoading = loadingPromptId === prompt.id; + const isGenerating = generatingPromptIds.has(prompt.id); + const isStarted = startedPrompts.has(prompt.id); + const isDisabled = loadingPromptId !== null || isGenerating; + + return ( + !isDisabled && handleSelectPrompt(prompt)} + > + +
+
+ {isLoading || isGenerating ? ( + + ) : isStarted ? ( + + ) : ( + + )} +
+
+

{prompt.title}

+

{prompt.description}

+ {(isLoading || isGenerating) && ( +

Generating in dashboard...

+ )} + {isStarted && !isGenerating && ( +

+ Already generated - check dashboard +

+ )} +
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts b/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts new file mode 100644 index 00000000..41e8f59c --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts @@ -0,0 +1,391 @@ +/** + * Guided prompts for ideation sessions + * Static data that provides pre-made prompts for different categories + */ + +import type { IdeaCategory, IdeationPrompt, PromptCategory } from '@automaker/types'; + +export const PROMPT_CATEGORIES: PromptCategory[] = [ + { + id: 'feature', + name: 'Features', + icon: 'Zap', + description: 'New capabilities and functionality', + }, + { + id: 'ux-ui', + name: 'UX/UI', + icon: 'Palette', + description: 'Design and user experience improvements', + }, + { + id: 'dx', + name: 'Developer Experience', + icon: 'Code', + description: 'Developer tooling and workflows', + }, + { + id: 'growth', + name: 'Growth', + icon: 'TrendingUp', + description: 'User engagement and retention', + }, + { + id: 'technical', + name: 'Technical', + icon: 'Cpu', + description: 'Architecture and infrastructure', + }, + { + id: 'security', + name: 'Security', + icon: 'Shield', + description: 'Security and privacy improvements', + }, + { + id: 'performance', + name: 'Performance', + icon: 'Gauge', + description: 'Speed and optimization', + }, + { + id: 'accessibility', + name: 'Accessibility', + icon: 'Accessibility', + description: 'Inclusive design for all users', + }, + { + id: 'analytics', + name: 'Analytics', + icon: 'BarChart3', + description: 'Data insights and tracking', + }, +]; + +export const GUIDED_PROMPTS: IdeationPrompt[] = [ + // Feature prompts + { + id: 'feature-missing', + category: 'feature', + title: 'Missing Features', + description: 'Discover features users might expect', + prompt: + "Analyze this codebase and identify features that users of similar applications typically expect but are missing here. Consider the app's domain, target users, and common patterns in similar products.", + }, + { + id: 'feature-automation', + category: 'feature', + title: 'Automation Opportunities', + description: 'Find manual processes that could be automated', + prompt: + 'Review this codebase and identify manual processes or repetitive tasks that could be automated. Look for patterns where users might be doing things repeatedly that software could handle.', + }, + { + id: 'feature-integrations', + category: 'feature', + title: 'Integration Ideas', + description: 'Identify valuable third-party integrations', + prompt: + "Based on this codebase, what third-party services or APIs would provide value if integrated? Consider the app's domain and what complementary services users might need.", + }, + { + id: 'feature-workflow', + category: 'feature', + title: 'Workflow Improvements', + description: 'Streamline user workflows', + prompt: + 'Analyze the user workflows in this application. What steps could be combined, eliminated, or automated? Where are users likely spending too much time on repetitive tasks?', + }, + + // UX/UI prompts + { + id: 'ux-friction', + category: 'ux-ui', + title: 'Friction Points', + description: 'Identify where users might get stuck', + prompt: + 'Analyze the user flows in this codebase and identify potential friction points. Where might users get confused, stuck, or frustrated? Look at form submissions, navigation, error states, and complex interactions.', + }, + { + id: 'ux-empty-states', + category: 'ux-ui', + title: 'Empty States', + description: 'Improve empty state experiences', + prompt: + "Review the components in this codebase and identify empty states that could be improved. How can we guide users when there's no content? Consider onboarding, helpful prompts, and sample data.", + }, + { + id: 'ux-accessibility', + category: 'ux-ui', + title: 'Accessibility Improvements', + description: 'Enhance accessibility and inclusivity', + prompt: + 'Analyze this codebase for accessibility improvements. Consider keyboard navigation, screen reader support, color contrast, focus states, and ARIA labels. What specific improvements would make this more accessible?', + }, + { + id: 'ux-mobile', + category: 'ux-ui', + title: 'Mobile Experience', + description: 'Optimize for mobile users', + prompt: + 'Review this codebase from a mobile-first perspective. What improvements would enhance the mobile user experience? Consider touch targets, responsive layouts, and mobile-specific interactions.', + }, + { + id: 'ux-feedback', + category: 'ux-ui', + title: 'User Feedback', + description: 'Improve feedback and status indicators', + prompt: + 'Analyze how this application communicates with users. Where are loading states, success messages, or error handling missing or unclear? What feedback would help users understand what is happening?', + }, + + // DX prompts + { + id: 'dx-documentation', + category: 'dx', + title: 'Documentation Gaps', + description: 'Identify missing documentation', + prompt: + 'Review this codebase and identify areas lacking documentation. What would help new developers understand the architecture, APIs, and conventions? Consider inline comments, READMEs, and API docs.', + }, + { + id: 'dx-testing', + category: 'dx', + title: 'Testing Improvements', + description: 'Enhance test coverage and quality', + prompt: + 'Analyze the testing patterns in this codebase. What areas need better test coverage? What types of tests are missing? Consider unit tests, integration tests, and end-to-end tests.', + }, + { + id: 'dx-tooling', + category: 'dx', + title: 'Developer Tooling', + description: 'Improve development workflows', + prompt: + 'Review the development setup and tooling in this codebase. What improvements would speed up development? Consider build times, hot reload, debugging tools, and developer scripts.', + }, + { + id: 'dx-error-handling', + category: 'dx', + title: 'Error Handling', + description: 'Improve error messages and debugging', + prompt: + 'Analyze error handling in this codebase. Where are error messages unclear or missing? What would help developers debug issues faster? Consider logging, error boundaries, and stack traces.', + }, + + // Growth prompts + { + id: 'growth-onboarding', + category: 'growth', + title: 'Onboarding Flow', + description: 'Improve new user experience', + prompt: + "Analyze this application's onboarding experience. How can we help new users understand the value and get started quickly? Consider tutorials, progressive disclosure, and quick wins.", + }, + { + id: 'growth-engagement', + category: 'growth', + title: 'User Engagement', + description: 'Increase user retention and activity', + prompt: + 'Review this application and suggest features that would increase user engagement and retention. What would bring users back daily? Consider notifications, streaks, social features, and personalization.', + }, + { + id: 'growth-sharing', + category: 'growth', + title: 'Shareability', + description: 'Make the app more shareable', + prompt: + 'How can this application be made more shareable? What features would encourage users to invite others or share their work? Consider collaboration, public profiles, and export features.', + }, + { + id: 'growth-monetization', + category: 'growth', + title: 'Monetization Ideas', + description: 'Identify potential revenue streams', + prompt: + 'Based on this codebase, what features or tiers could support monetization? Consider premium features, usage limits, team features, and integrations that users would pay for.', + }, + + // Technical prompts + { + id: 'tech-performance', + category: 'technical', + title: 'Performance Optimization', + description: 'Identify performance bottlenecks', + prompt: + 'Analyze this codebase for performance optimization opportunities. Where are the likely bottlenecks? Consider database queries, API calls, bundle size, rendering, and caching strategies.', + }, + { + id: 'tech-architecture', + category: 'technical', + title: 'Architecture Review', + description: 'Evaluate and improve architecture', + prompt: + 'Review the architecture of this codebase. What improvements would make it more maintainable, scalable, or testable? Consider separation of concerns, dependency management, and patterns.', + }, + { + id: 'tech-debt', + category: 'technical', + title: 'Technical Debt', + description: 'Identify areas needing refactoring', + prompt: + 'Identify technical debt in this codebase. What areas are becoming hard to maintain or understand? What refactoring would have the highest impact? Consider duplicated code, complexity, and outdated patterns.', + }, + { + id: 'tech-security', + category: 'technical', + title: 'Security Review', + description: 'Identify security improvements', + prompt: + 'Review this codebase for security improvements. What best practices are missing? Consider authentication, authorization, input validation, and data protection. Note: This is for improvement suggestions, not a security audit.', + }, + + // Security prompts + { + id: 'security-auth', + category: 'security', + title: 'Authentication Security', + description: 'Review authentication mechanisms', + prompt: + 'Analyze the authentication system in this codebase. What security improvements would strengthen user authentication? Consider password policies, session management, MFA, and token handling.', + }, + { + id: 'security-data', + category: 'security', + title: 'Data Protection', + description: 'Protect sensitive user data', + prompt: + 'Review how this application handles sensitive data. What improvements would better protect user privacy? Consider encryption, data minimization, secure storage, and data retention policies.', + }, + { + id: 'security-input', + category: 'security', + title: 'Input Validation', + description: 'Prevent injection attacks', + prompt: + 'Analyze input handling in this codebase. Where could input validation be strengthened? Consider SQL injection, XSS, command injection, and file upload vulnerabilities.', + }, + { + id: 'security-api', + category: 'security', + title: 'API Security', + description: 'Secure API endpoints', + prompt: + 'Review the API security in this codebase. What improvements would make the API more secure? Consider rate limiting, authorization, CORS, and request validation.', + }, + + // Performance prompts + { + id: 'perf-frontend', + category: 'performance', + title: 'Frontend Performance', + description: 'Optimize UI rendering and loading', + prompt: + 'Analyze the frontend performance of this application. What optimizations would improve load times and responsiveness? Consider bundle splitting, lazy loading, memoization, and render optimization.', + }, + { + id: 'perf-backend', + category: 'performance', + title: 'Backend Performance', + description: 'Optimize server-side operations', + prompt: + 'Review backend performance in this codebase. What optimizations would improve response times? Consider database queries, caching strategies, async operations, and resource pooling.', + }, + { + id: 'perf-database', + category: 'performance', + title: 'Database Optimization', + description: 'Improve query performance', + prompt: + 'Analyze database interactions in this codebase. What optimizations would improve data access performance? Consider indexing, query optimization, denormalization, and connection pooling.', + }, + { + id: 'perf-caching', + category: 'performance', + title: 'Caching Strategies', + description: 'Implement effective caching', + prompt: + 'Review caching opportunities in this application. Where would caching provide the most benefit? Consider API responses, computed values, static assets, and session data.', + }, + + // Accessibility prompts + { + id: 'a11y-keyboard', + category: 'accessibility', + title: 'Keyboard Navigation', + description: 'Enable full keyboard access', + prompt: + 'Analyze keyboard accessibility in this codebase. What improvements would enable users to navigate entirely with keyboard? Consider focus management, tab order, and keyboard shortcuts.', + }, + { + id: 'a11y-screen-reader', + category: 'accessibility', + title: 'Screen Reader Support', + description: 'Improve screen reader experience', + prompt: + 'Review screen reader compatibility in this application. What improvements would help users with visual impairments? Consider ARIA labels, semantic HTML, live regions, and alt text.', + }, + { + id: 'a11y-visual', + category: 'accessibility', + title: 'Visual Accessibility', + description: 'Improve visual design for all users', + prompt: + 'Analyze visual accessibility in this codebase. What improvements would help users with visual impairments? Consider color contrast, text sizing, focus indicators, and reduced motion.', + }, + { + id: 'a11y-forms', + category: 'accessibility', + title: 'Accessible Forms', + description: 'Make forms usable for everyone', + prompt: + 'Review form accessibility in this application. What improvements would make forms more accessible? Consider labels, error messages, required field indicators, and input assistance.', + }, + + // Analytics prompts + { + id: 'analytics-tracking', + category: 'analytics', + title: 'User Tracking', + description: 'Track key user behaviors', + prompt: + 'Analyze this application for analytics opportunities. What user behaviors should be tracked to understand engagement? Consider page views, feature usage, conversion funnels, and session duration.', + }, + { + id: 'analytics-metrics', + category: 'analytics', + title: 'Key Metrics', + description: 'Define success metrics', + prompt: + 'Based on this codebase, what key metrics should be tracked? Consider user acquisition, retention, engagement, and feature adoption. What dashboards would be most valuable?', + }, + { + id: 'analytics-errors', + category: 'analytics', + title: 'Error Monitoring', + description: 'Track and analyze errors', + prompt: + 'Review error handling in this codebase for monitoring opportunities. What error tracking would help identify and fix issues faster? Consider error aggregation, alerting, and stack traces.', + }, + { + id: 'analytics-performance', + category: 'analytics', + title: 'Performance Monitoring', + description: 'Track application performance', + prompt: + 'Analyze this application for performance monitoring opportunities. What metrics would help identify bottlenecks? Consider load times, API response times, and resource usage.', + }, +]; + +export function getPromptsByCategory(category: IdeaCategory): IdeationPrompt[] { + return GUIDED_PROMPTS.filter((p) => p.category === category); +} + +export function getPromptById(id: string): IdeationPrompt | undefined { + return GUIDED_PROMPTS.find((p) => p.id === id); +} + +export function getCategoryById(id: IdeaCategory): PromptCategory | undefined { + return PROMPT_CATEGORIES.find((c) => c.id === id); +} diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx new file mode 100644 index 00000000..aa962ae8 --- /dev/null +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -0,0 +1,208 @@ +/** + * IdeationView - Main view for brainstorming and idea management + * Dashboard-first design with Generate Ideas flow + */ + +import { useCallback } from 'react'; +import { useIdeationStore } from '@/store/ideation-store'; +import { useAppStore } from '@/store/app-store'; +import { PromptCategoryGrid } from './components/prompt-category-grid'; +import { PromptList } from './components/prompt-list'; +import { IdeationDashboard } from './components/ideation-dashboard'; +import { getCategoryById } from './data/guided-prompts'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react'; +import type { IdeaCategory } from '@automaker/types'; +import type { IdeationMode } from '@/store/ideation-store'; + +// Get subtitle text based on current mode +function getSubtitle(currentMode: IdeationMode, selectedCategory: IdeaCategory | null): string { + if (currentMode === 'dashboard') { + return 'Review and accept generated ideas'; + } + if (currentMode === 'prompts') { + if (selectedCategory) { + const categoryInfo = getCategoryById(selectedCategory); + return `Select a prompt from ${categoryInfo?.name || 'category'}`; + } + return 'Select a category to generate ideas'; + } + return ''; +} + +// Breadcrumb component - compact inline breadcrumbs +function IdeationBreadcrumbs({ + currentMode, + selectedCategory, + onNavigate, +}: { + currentMode: IdeationMode; + selectedCategory: IdeaCategory | null; + onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void; +}) { + const categoryInfo = selectedCategory ? getCategoryById(selectedCategory) : null; + + // On dashboard, no breadcrumbs needed (it's the root) + if (currentMode === 'dashboard') { + return null; + } + + return ( + + ); +} + +// Header shown on all pages - matches other view headers +function IdeationHeader({ + currentMode, + selectedCategory, + onNavigate, + onGenerateIdeas, + onBack, +}: { + currentMode: IdeationMode; + selectedCategory: IdeaCategory | null; + onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void; + onGenerateIdeas: () => void; + onBack: () => void; +}) { + const subtitle = getSubtitle(currentMode, selectedCategory); + const showBackButton = currentMode === 'prompts'; + + return ( +
+
+ {showBackButton && ( + + )} +
+
+ +

Ideation

+
+ {currentMode === 'dashboard' ? ( +

{subtitle}

+ ) : ( + + )} +
+
+ +
+ +
+
+ ); +} + +export function IdeationView() { + const currentProject = useAppStore((s) => s.currentProject); + const { currentMode, selectedCategory, setMode, setCategory } = useIdeationStore(); + + const handleNavigate = useCallback( + (mode: IdeationMode, category?: IdeaCategory | null) => { + setMode(mode); + if (category !== undefined) { + setCategory(category); + } else if (mode !== 'prompts') { + setCategory(null); + } + }, + [setMode, setCategory] + ); + + const handleSelectCategory = useCallback( + (category: IdeaCategory) => { + setCategory(category); + }, + [setCategory] + ); + + const handleBackFromPrompts = useCallback(() => { + // If viewing a category, go back to category grid + if (selectedCategory) { + setCategory(null); + return; + } + // Otherwise, go back to dashboard + setMode('dashboard'); + }, [selectedCategory, setCategory, setMode]); + + const handleGenerateIdeas = useCallback(() => { + setMode('prompts'); + setCategory(null); + }, [setMode, setCategory]); + + if (!currentProject) { + return ( +
+
+

Open a project to start brainstorming ideas

+
+
+ ); + } + + return ( +
+ {/* Header with breadcrumbs - always shown */} + + + {/* Dashboard - main view */} + {currentMode === 'dashboard' && } + + {/* Prompts - category selection */} + {currentMode === 'prompts' && !selectedCategory && ( + + )} + + {/* Prompts - prompt selection within category */} + {currentMode === 'prompts' && selectedCategory && ( + + )} +
+ ); +} diff --git a/apps/ui/src/hooks/use-keyboard-shortcuts.ts b/apps/ui/src/hooks/use-keyboard-shortcuts.ts index 4f5a0234..ae3c130a 100644 --- a/apps/ui/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/ui/src/hooks/use-keyboard-shortcuts.ts @@ -1,5 +1,5 @@ -import { useEffect, useCallback } from 'react'; -import { useAppStore, parseShortcut } from '@/store/app-store'; +import { useEffect, useCallback, useMemo } from 'react'; +import { useAppStore, parseShortcut, DEFAULT_KEYBOARD_SHORTCUTS } from '@/store/app-store'; export interface KeyboardShortcut { key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K" @@ -237,8 +237,18 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { /** * Hook to get current keyboard shortcuts from store * This replaces the static constants and allows customization + * Merges with defaults to ensure new shortcuts are always available */ export function useKeyboardShortcutsConfig() { const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); - return keyboardShortcuts; + + // Merge with defaults to ensure new shortcuts are available + // even if user's persisted state predates them + return useMemo( + () => ({ + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }), + [keyboardShortcuts] + ); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 58125806..ef9c6bb9 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -13,6 +13,16 @@ import type { AgentModel, GitHubComment, IssueCommentsResult, + Idea, + IdeaCategory, + IdeationSession, + IdeationMessage, + ProjectAnalysisResult, + AnalysisSuggestion, + StartSessionOptions, + CreateIdeaInput, + UpdateIdeaInput, + ConvertToFeatureOptions, } from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; @@ -30,6 +40,94 @@ export type { IssueCommentsResult, }; +// Re-export ideation types +export type { + Idea, + IdeaCategory, + IdeationSession, + IdeationMessage, + ProjectAnalysisResult, + AnalysisSuggestion, + StartSessionOptions, + CreateIdeaInput, + UpdateIdeaInput, + ConvertToFeatureOptions, +}; + +// Ideation API interface +export interface IdeationAPI { + // Session management + startSession: ( + projectPath: string, + options?: StartSessionOptions + ) => Promise<{ success: boolean; session?: IdeationSession; error?: string }>; + getSession: ( + projectPath: string, + sessionId: string + ) => Promise<{ + success: boolean; + session?: IdeationSession; + messages?: IdeationMessage[]; + error?: string; + }>; + sendMessage: ( + sessionId: string, + message: string, + options?: { imagePaths?: string[]; model?: string } + ) => Promise<{ success: boolean; error?: string }>; + stopSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>; + + // Ideas CRUD + listIdeas: (projectPath: string) => Promise<{ success: boolean; ideas?: Idea[]; error?: string }>; + createIdea: ( + projectPath: string, + idea: CreateIdeaInput + ) => Promise<{ success: boolean; idea?: Idea; error?: string }>; + getIdea: ( + projectPath: string, + ideaId: string + ) => Promise<{ success: boolean; idea?: Idea; error?: string }>; + updateIdea: ( + projectPath: string, + ideaId: string, + updates: UpdateIdeaInput + ) => Promise<{ success: boolean; idea?: Idea; error?: string }>; + deleteIdea: ( + projectPath: string, + ideaId: string + ) => Promise<{ success: boolean; error?: string }>; + + // Project analysis + analyzeProject: ( + projectPath: string + ) => Promise<{ success: boolean; analysis?: ProjectAnalysisResult; error?: string }>; + + // Generate suggestions from a prompt + generateSuggestions: ( + projectPath: string, + promptId: string, + category: IdeaCategory, + count?: number + ) => Promise<{ success: boolean; suggestions?: AnalysisSuggestion[]; error?: string }>; + + // Convert to feature + convertToFeature: ( + projectPath: string, + ideaId: string, + options?: ConvertToFeatureOptions + ) => Promise<{ success: boolean; feature?: any; featureId?: string; error?: string }>; + + // Add suggestion directly to board as feature + addSuggestionToBoard: ( + projectPath: string, + suggestion: AnalysisSuggestion + ) => Promise<{ success: boolean; featureId?: string; error?: string }>; + + // Event subscriptions + onStream: (callback: (event: any) => void) => () => void; + onAnalysisEvent: (callback: (event: any) => void) => () => void; +} + export interface FileEntry { name: string; isDirectory: boolean; @@ -647,6 +745,7 @@ export interface ElectronAPI { error?: string; }>; }; + ideation?: IdeationAPI; } // Note: Window interface is declared in @/types/electron.d.ts diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 32bd88f8..087470ef 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -26,6 +26,13 @@ import type { GitHubPR, IssueValidationInput, IssueValidationEvent, + IdeationAPI, + IdeaCategory, + AnalysisSuggestion, + StartSessionOptions, + CreateIdeaInput, + UpdateIdeaInput, + ConvertToFeatureOptions, } from './electron'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; @@ -368,7 +375,9 @@ type EventType = | 'suggestions:event' | 'spec-regeneration:event' | 'issue-validation:event' - | 'backlog-plan:event'; + | 'backlog-plan:event' + | 'ideation:stream' + | 'ideation:analysis'; type EventCallback = (payload: unknown) => void; @@ -1640,6 +1649,88 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Ideation API - brainstorming and idea management + ideation: IdeationAPI = { + startSession: (projectPath: string, options?: StartSessionOptions) => + this.post('/api/ideation/session/start', { projectPath, options }), + + getSession: (projectPath: string, sessionId: string) => + this.post('/api/ideation/session/get', { projectPath, sessionId }), + + sendMessage: ( + sessionId: string, + message: string, + options?: { imagePaths?: string[]; model?: string } + ) => this.post('/api/ideation/session/message', { sessionId, message, options }), + + stopSession: (sessionId: string) => this.post('/api/ideation/session/stop', { sessionId }), + + listIdeas: (projectPath: string) => this.post('/api/ideation/ideas/list', { projectPath }), + + createIdea: (projectPath: string, idea: CreateIdeaInput) => + this.post('/api/ideation/ideas/create', { projectPath, idea }), + + getIdea: (projectPath: string, ideaId: string) => + this.post('/api/ideation/ideas/get', { projectPath, ideaId }), + + updateIdea: (projectPath: string, ideaId: string, updates: UpdateIdeaInput) => + this.post('/api/ideation/ideas/update', { projectPath, ideaId, updates }), + + deleteIdea: (projectPath: string, ideaId: string) => + this.post('/api/ideation/ideas/delete', { projectPath, ideaId }), + + analyzeProject: (projectPath: string) => this.post('/api/ideation/analyze', { projectPath }), + + generateSuggestions: ( + projectPath: string, + promptId: string, + category: IdeaCategory, + count?: number + ) => + this.post('/api/ideation/suggestions/generate', { projectPath, promptId, category, count }), + + convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) => + this.post('/api/ideation/convert', { projectPath, ideaId, ...options }), + + addSuggestionToBoard: async (projectPath: string, suggestion: AnalysisSuggestion) => { + // Create a feature directly from the suggestion + const result = await this.post<{ success: boolean; feature?: Feature; error?: string }>( + '/api/features/create', + { + projectPath, + feature: { + title: suggestion.title, + description: + suggestion.description + + (suggestion.rationale ? `\n\n**Rationale:** ${suggestion.rationale}` : ''), + category: + suggestion.category === 'ux-ui' + ? 'enhancement' + : suggestion.category === 'dx' + ? 'chore' + : suggestion.category === 'technical' + ? 'refactor' + : 'feature', + status: 'backlog', + }, + } + ); + return { + success: result.success, + featureId: result.feature?.id, + error: result.error, + }; + }, + + onStream: (callback: (event: any) => void): (() => void) => { + return this.subscribeToEvent('ideation:stream', callback as EventCallback); + }, + + onAnalysisEvent: (callback: (event: any) => void): (() => void) => { + return this.subscribeToEvent('ideation:analysis', callback as EventCallback); + }, + }; + // MCP API - Test MCP server connections and list tools // SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent // drive-by command execution attacks. Servers must be saved first. diff --git a/apps/ui/src/routes/ideation.tsx b/apps/ui/src/routes/ideation.tsx new file mode 100644 index 00000000..6352d6aa --- /dev/null +++ b/apps/ui/src/routes/ideation.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { IdeationView } from '@/components/views/ideation-view'; + +export const Route = createFileRoute('/ideation')({ + component: IdeationView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index a57e4d93..ace36cf6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -29,7 +29,8 @@ export type ViewMode = | 'profiles' | 'running-agents' | 'terminal' - | 'wiki'; + | 'wiki' + | 'ideation'; export type ThemeMode = | 'light' @@ -154,6 +155,9 @@ export interface KeyboardShortcuts { settings: string; profiles: string; terminal: string; + ideation: string; + githubIssues: string; + githubPrs: string; // UI shortcuts toggleSidebar: string; @@ -186,6 +190,9 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { settings: 'S', profiles: 'M', terminal: 'T', + ideation: 'I', + githubIssues: 'G', + githubPrs: 'R', // UI toggleSidebar: '`', diff --git a/apps/ui/src/store/ideation-store.ts b/apps/ui/src/store/ideation-store.ts new file mode 100644 index 00000000..cfc564ff --- /dev/null +++ b/apps/ui/src/store/ideation-store.ts @@ -0,0 +1,324 @@ +/** + * Ideation Store - State management for brainstorming and idea management + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { + Idea, + IdeaCategory, + IdeaStatus, + IdeationPrompt, + AnalysisSuggestion, + ProjectAnalysisResult, +} from '@automaker/types'; + +// ============================================================================ +// Generation Job Types +// ============================================================================ + +export type GenerationJobStatus = 'generating' | 'ready' | 'error'; + +export interface GenerationJob { + id: string; + prompt: IdeationPrompt; + status: GenerationJobStatus; + suggestions: AnalysisSuggestion[]; + error: string | null; + startedAt: string; + completedAt: string | null; +} + +// ============================================================================ +// State Interface +// ============================================================================ + +export type IdeationMode = 'dashboard' | 'prompts'; + +interface IdeationState { + // Ideas (saved for later) + ideas: Idea[]; + selectedIdeaId: string | null; + + // Generation jobs (multiple concurrent generations) + generationJobs: GenerationJob[]; + selectedJobId: string | null; + + // Legacy - keep for backwards compat during transition + suggestions: AnalysisSuggestion[]; + selectedPrompt: IdeationPrompt | null; + isGenerating: boolean; + generatingError: string | null; + + // Analysis + analysisResult: ProjectAnalysisResult | null; + isAnalyzing: boolean; + analysisProgress: number; + analysisMessage: string; + + // UI state + currentMode: IdeationMode; + selectedCategory: IdeaCategory | null; + filterStatus: IdeaStatus | 'all'; +} + +// ============================================================================ +// Actions Interface +// ============================================================================ + +interface IdeationActions { + // Ideas + setIdeas: (ideas: Idea[]) => void; + addIdea: (idea: Idea) => void; + updateIdea: (id: string, updates: Partial) => void; + removeIdea: (id: string) => void; + setSelectedIdea: (id: string | null) => void; + getSelectedIdea: () => Idea | null; + + // Generation Jobs + addGenerationJob: (prompt: IdeationPrompt) => string; + updateJobStatus: ( + jobId: string, + status: GenerationJobStatus, + suggestions?: AnalysisSuggestion[], + error?: string + ) => void; + removeJob: (jobId: string) => void; + clearCompletedJobs: () => void; + setSelectedJob: (jobId: string | null) => void; + getJob: (jobId: string) => GenerationJob | null; + removeSuggestionFromJob: (jobId: string, suggestionId: string) => void; + appendSuggestionsToJob: (jobId: string, suggestions: AnalysisSuggestion[]) => void; + setJobGenerating: (jobId: string, generating: boolean) => void; + + // Legacy Suggestions (kept for backwards compat) + setSuggestions: (suggestions: AnalysisSuggestion[]) => void; + clearSuggestions: () => void; + removeSuggestion: (id: string) => void; + setSelectedPrompt: (prompt: IdeationPrompt | null) => void; + setIsGenerating: (isGenerating: boolean) => void; + setGeneratingError: (error: string | null) => void; + + // Analysis + setAnalysisResult: (result: ProjectAnalysisResult | null) => void; + setIsAnalyzing: (isAnalyzing: boolean) => void; + setAnalysisProgress: (progress: number, message?: string) => void; + + // UI + setMode: (mode: IdeationMode) => void; + setCategory: (category: IdeaCategory | null) => void; + setFilterStatus: (status: IdeaStatus | 'all') => void; + + // Reset + reset: () => void; + resetSuggestions: () => void; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: IdeationState = { + ideas: [], + selectedIdeaId: null, + generationJobs: [], + selectedJobId: null, + suggestions: [], + selectedPrompt: null, + isGenerating: false, + generatingError: null, + analysisResult: null, + isAnalyzing: false, + analysisProgress: 0, + analysisMessage: '', + currentMode: 'dashboard', + selectedCategory: null, + filterStatus: 'all', +}; + +// ============================================================================ +// Store +// ============================================================================ + +export const useIdeationStore = create()( + persist( + (set, get) => ({ + ...initialState, + + // Ideas + setIdeas: (ideas) => set({ ideas }), + + addIdea: (idea) => + set((state) => ({ + ideas: [idea, ...state.ideas], + })), + + updateIdea: (id, updates) => + set((state) => ({ + ideas: state.ideas.map((idea) => (idea.id === id ? { ...idea, ...updates } : idea)), + })), + + removeIdea: (id) => + set((state) => ({ + ideas: state.ideas.filter((idea) => idea.id !== id), + selectedIdeaId: state.selectedIdeaId === id ? null : state.selectedIdeaId, + })), + + setSelectedIdea: (id) => set({ selectedIdeaId: id }), + + getSelectedIdea: () => { + const state = get(); + return state.ideas.find((idea) => idea.id === state.selectedIdeaId) || null; + }, + + // Generation Jobs + addGenerationJob: (prompt) => { + const jobId = `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const job: GenerationJob = { + id: jobId, + prompt, + status: 'generating', + suggestions: [], + error: null, + startedAt: new Date().toISOString(), + completedAt: null, + }; + set((state) => ({ + generationJobs: [job, ...state.generationJobs], + })); + return jobId; + }, + + updateJobStatus: (jobId, status, suggestions, error) => + set((state) => ({ + generationJobs: state.generationJobs.map((job) => + job.id === jobId + ? { + ...job, + status, + suggestions: suggestions || job.suggestions, + error: error || null, + completedAt: status !== 'generating' ? new Date().toISOString() : null, + } + : job + ), + })), + + removeJob: (jobId) => + set((state) => ({ + generationJobs: state.generationJobs.filter((job) => job.id !== jobId), + selectedJobId: state.selectedJobId === jobId ? null : state.selectedJobId, + })), + + clearCompletedJobs: () => + set((state) => ({ + generationJobs: state.generationJobs.filter((job) => job.status === 'generating'), + })), + + setSelectedJob: (jobId) => set({ selectedJobId: jobId }), + + getJob: (jobId) => { + const state = get(); + return state.generationJobs.find((job) => job.id === jobId) || null; + }, + + removeSuggestionFromJob: (jobId, suggestionId) => + set((state) => ({ + generationJobs: state.generationJobs.map((job) => + job.id === jobId + ? { + ...job, + suggestions: job.suggestions.filter((s) => s.id !== suggestionId), + } + : job + ), + })), + + appendSuggestionsToJob: (jobId, suggestions) => + set((state) => ({ + generationJobs: state.generationJobs.map((job) => + job.id === jobId + ? { + ...job, + suggestions: [...job.suggestions, ...suggestions], + status: 'ready' as const, + } + : job + ), + })), + + setJobGenerating: (jobId, generating) => + set((state) => ({ + generationJobs: state.generationJobs.map((job) => + job.id === jobId + ? { + ...job, + status: generating ? ('generating' as const) : ('ready' as const), + } + : job + ), + })), + + // Suggestions (legacy) + setSuggestions: (suggestions) => set({ suggestions }), + + clearSuggestions: () => set({ suggestions: [], generatingError: null }), + + removeSuggestion: (id) => + set((state) => ({ + suggestions: state.suggestions.filter((s) => s.id !== id), + })), + + setSelectedPrompt: (prompt) => set({ selectedPrompt: prompt }), + + setIsGenerating: (isGenerating) => set({ isGenerating }), + + setGeneratingError: (error) => set({ generatingError: error }), + + // Analysis + setAnalysisResult: (result) => set({ analysisResult: result }), + + setIsAnalyzing: (isAnalyzing) => + set({ + isAnalyzing, + analysisProgress: isAnalyzing ? 0 : get().analysisProgress, + analysisMessage: isAnalyzing ? 'Starting analysis...' : '', + }), + + setAnalysisProgress: (progress, message) => + set({ + analysisProgress: progress, + analysisMessage: message || get().analysisMessage, + }), + + // UI + setMode: (mode) => set({ currentMode: mode }), + + setCategory: (category) => set({ selectedCategory: category }), + + setFilterStatus: (status) => set({ filterStatus: status }), + + // Reset + reset: () => set(initialState), + + resetSuggestions: () => + set({ + suggestions: [], + selectedPrompt: null, + isGenerating: false, + generatingError: null, + }), + }), + { + name: 'automaker-ideation-store', + version: 3, + partialize: (state) => ({ + // Only persist these fields + ideas: state.ideas, + generationJobs: state.generationJobs, + analysisResult: state.analysisResult, + filterStatus: state.filterStatus, + }), + } + ) +); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 81ffe224..38ddda1b 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -23,6 +23,17 @@ export { getCredentialsPath, getProjectSettingsPath, ensureDataDir, + // Ideation paths + getIdeationDir, + getIdeasDir, + getIdeaDir, + getIdeaPath, + getIdeaAttachmentsDir, + getIdeationSessionsDir, + getIdeationSessionPath, + getIdeationDraftsDir, + getIdeationAnalysisPath, + ensureIdeationDir, } from './paths.js'; // Subprocess management diff --git a/libs/platform/src/paths.ts b/libs/platform/src/paths.ts index 6fea2200..612f155c 100644 --- a/libs/platform/src/paths.ts +++ b/libs/platform/src/paths.ts @@ -188,6 +188,140 @@ export async function ensureAutomakerDir(projectPath: string): Promise { return automakerDir; } +// ============================================================================ +// Ideation Paths +// ============================================================================ + +/** + * Get the ideation directory for a project + * + * Contains ideas, sessions, and drafts for brainstorming. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/ideation + */ +export function getIdeationDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), 'ideation'); +} + +/** + * Get the ideas directory for a project + * + * Contains subdirectories for each idea, keyed by ideaId. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/ideation/ideas + */ +export function getIdeasDir(projectPath: string): string { + return path.join(getIdeationDir(projectPath), 'ideas'); +} + +/** + * Get the directory for a specific idea + * + * Contains idea metadata and attachments. + * + * @param projectPath - Absolute path to project directory + * @param ideaId - Idea identifier + * @returns Absolute path to {projectPath}/.automaker/ideation/ideas/{ideaId} + */ +export function getIdeaDir(projectPath: string, ideaId: string): string { + return path.join(getIdeasDir(projectPath), ideaId); +} + +/** + * Get the idea metadata file path + * + * Stores the idea JSON data. + * + * @param projectPath - Absolute path to project directory + * @param ideaId - Idea identifier + * @returns Absolute path to {projectPath}/.automaker/ideation/ideas/{ideaId}/idea.json + */ +export function getIdeaPath(projectPath: string, ideaId: string): string { + return path.join(getIdeaDir(projectPath, ideaId), 'idea.json'); +} + +/** + * Get the idea attachments directory + * + * Stores images and other attachments for an idea. + * + * @param projectPath - Absolute path to project directory + * @param ideaId - Idea identifier + * @returns Absolute path to {projectPath}/.automaker/ideation/ideas/{ideaId}/attachments + */ +export function getIdeaAttachmentsDir(projectPath: string, ideaId: string): string { + return path.join(getIdeaDir(projectPath, ideaId), 'attachments'); +} + +/** + * Get the ideation sessions directory for a project + * + * Contains conversation history for ideation sessions. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/ideation/sessions + */ +export function getIdeationSessionsDir(projectPath: string): string { + return path.join(getIdeationDir(projectPath), 'sessions'); +} + +/** + * Get the session file path for an ideation session + * + * Stores the session messages and metadata. + * + * @param projectPath - Absolute path to project directory + * @param sessionId - Session identifier + * @returns Absolute path to {projectPath}/.automaker/ideation/sessions/{sessionId}.json + */ +export function getIdeationSessionPath(projectPath: string, sessionId: string): string { + return path.join(getIdeationSessionsDir(projectPath), `${sessionId}.json`); +} + +/** + * Get the ideation drafts directory for a project + * + * Stores unsaved conversation drafts. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/ideation/drafts + */ +export function getIdeationDraftsDir(projectPath: string): string { + return path.join(getIdeationDir(projectPath), 'drafts'); +} + +/** + * Get the project analysis result file path + * + * Stores the cached project analysis result. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/ideation/analysis.json + */ +export function getIdeationAnalysisPath(projectPath: string): string { + return path.join(getIdeationDir(projectPath), 'analysis.json'); +} + +/** + * Create the ideation directory structure for a project if it doesn't exist + * + * Creates {projectPath}/.automaker/ideation with all subdirectories. + * Safe to call multiple times - uses recursive: true. + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to the created ideation directory path + */ +export async function ensureIdeationDir(projectPath: string): Promise { + const ideationDir = getIdeationDir(projectPath); + await secureFs.mkdir(ideationDir, { recursive: true }); + await secureFs.mkdir(getIdeasDir(projectPath), { recursive: true }); + await secureFs.mkdir(getIdeationSessionsDir(projectPath), { recursive: true }); + await secureFs.mkdir(getIdeationDraftsDir(projectPath), { recursive: true }); + return ideationDir; +} + // ============================================================================ // Global Settings Paths (stored in DATA_DIR from app.getPath('userData')) // ============================================================================ diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 805f48e4..091b3e90 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -26,6 +26,15 @@ export type EventType = | 'project:analysis-error' | 'suggestions:event' | 'spec-regeneration:event' - | 'issue-validation:event'; + | 'issue-validation:event' + | 'ideation:stream' + | 'ideation:session-started' + | 'ideation:session-ended' + | 'ideation:analysis' + | 'ideation:analysis-started' + | 'ideation:analysis-progress' + | 'ideation:analysis-complete' + | 'ideation:analysis-error' + | 'ideation:suggestions'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/ideation.ts b/libs/types/src/ideation.ts new file mode 100644 index 00000000..c1c80903 --- /dev/null +++ b/libs/types/src/ideation.ts @@ -0,0 +1,230 @@ +/** + * Ideation types for AutoMaker brainstorming and idea management + */ + +// ============================================================================ +// Core Types +// ============================================================================ + +export type IdeaCategory = + | 'feature' + | 'ux-ui' + | 'dx' + | 'growth' + | 'technical' + | 'security' + | 'performance' + | 'accessibility' + | 'analytics'; +export type IdeaStatus = 'raw' | 'refined' | 'ready' | 'archived'; +export type ImpactLevel = 'low' | 'medium' | 'high'; +export type EffortLevel = 'low' | 'medium' | 'high'; + +// ============================================================================ +// Idea Entity +// ============================================================================ + +export interface IdeaAttachment { + id: string; + type: 'image' | 'link' | 'reference'; + path?: string; + url?: string; + description?: string; + [key: string]: unknown; +} + +export interface Idea { + id: string; + title: string; + description: string; + category: IdeaCategory; + status: IdeaStatus; + impact: ImpactLevel; + effort: EffortLevel; + + // Conversation context + conversationId?: string; + sourcePromptId?: string; + + // Content + attachments?: IdeaAttachment[]; + userStories?: string[]; + notes?: string; + + // Metadata + createdAt: string; + updatedAt: string; + archivedAt?: string; + + // Extensibility + [key: string]: unknown; +} + +// ============================================================================ +// Ideation Session +// ============================================================================ + +export type IdeationSessionStatus = 'active' | 'completed' | 'abandoned'; + +export interface IdeationSession { + id: string; + projectPath: string; + promptCategory?: IdeaCategory; + promptId?: string; + status: IdeationSessionStatus; + createdAt: string; + updatedAt: string; +} + +export interface IdeationMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: string; + savedAsIdeaId?: string; +} + +export interface IdeationSessionWithMessages extends IdeationSession { + messages: IdeationMessage[]; +} + +// ============================================================================ +// Guided Prompts +// ============================================================================ + +export interface PromptCategory { + id: IdeaCategory; + name: string; + icon: string; + description: string; +} + +export interface IdeationPrompt { + id: string; + category: IdeaCategory; + title: string; + description: string; + prompt: string; + icon?: string; +} + +// ============================================================================ +// Project Analysis +// ============================================================================ + +export interface AnalysisFileInfo { + path: string; + type: 'route' | 'component' | 'service' | 'model' | 'config' | 'test' | 'other'; + name: string; +} + +export interface AnalysisSuggestion { + id: string; + category: IdeaCategory; + title: string; + description: string; + rationale: string; + relatedFiles?: string[]; + priority: 'high' | 'medium' | 'low'; +} + +export interface ProjectAnalysisResult { + projectPath: string; + analyzedAt: string; + + // Structure analysis + totalFiles: number; + routes: AnalysisFileInfo[]; + components: AnalysisFileInfo[]; + services: AnalysisFileInfo[]; + + // Tech stack detection + framework?: string; + language?: string; + dependencies?: string[]; + + // AI-generated suggestions + suggestions: AnalysisSuggestion[]; + + // Summary + summary: string; +} + +// ============================================================================ +// API Types +// ============================================================================ + +export interface StartSessionOptions { + promptId?: string; + promptCategory?: IdeaCategory; + initialMessage?: string; +} + +export interface SendMessageOptions { + imagePaths?: string[]; + model?: string; +} + +export interface CreateIdeaInput { + title: string; + description: string; + category: IdeaCategory; + status?: IdeaStatus; + impact?: ImpactLevel; + effort?: EffortLevel; + conversationId?: string; + sourcePromptId?: string; + userStories?: string[]; + notes?: string; +} + +export interface UpdateIdeaInput { + title?: string; + description?: string; + category?: IdeaCategory; + status?: IdeaStatus; + impact?: ImpactLevel; + effort?: EffortLevel; + userStories?: string[]; + notes?: string; +} + +export interface ConvertToFeatureOptions { + column?: string; + dependencies?: string[]; + tags?: string[]; + keepIdea?: boolean; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +export type IdeationEventType = + | 'ideation:stream' + | 'ideation:session-started' + | 'ideation:session-ended' + | 'ideation:analysis-started' + | 'ideation:analysis-progress' + | 'ideation:analysis-complete' + | 'ideation:analysis-error'; + +export interface IdeationStreamEvent { + type: 'ideation:stream'; + sessionId: string; + content: string; + done: boolean; +} + +export interface IdeationAnalysisEvent { + type: + | 'ideation:analysis-started' + | 'ideation:analysis-progress' + | 'ideation:analysis-complete' + | 'ideation:analysis-error'; + projectPath: string; + progress?: number; + message?: string; + result?: ProjectAnalysisResult; + error?: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index be714877..62a0a9be 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -143,3 +143,30 @@ export type { // Port configuration export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; + +// Ideation types +export type { + IdeaCategory, + IdeaStatus, + ImpactLevel, + EffortLevel, + IdeaAttachment, + Idea, + IdeationSessionStatus, + IdeationSession, + IdeationMessage, + IdeationSessionWithMessages, + PromptCategory, + IdeationPrompt, + AnalysisFileInfo, + AnalysisSuggestion, + ProjectAnalysisResult, + StartSessionOptions, + SendMessageOptions, + CreateIdeaInput, + UpdateIdeaInput, + ConvertToFeatureOptions, + IdeationEventType, + IdeationStreamEvent, + IdeationAnalysisEvent, +} from './ideation.js'; From 019d6dd7bd8c6fe6609a635d40dbb902a3317127 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 22:50:42 -0500 Subject: [PATCH 06/17] fix memory leak --- TODO.md | 17 ++ apps/ui/src/app.tsx | 13 +- package.json | 1 + start.mjs | 722 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 TODO.md create mode 100755 start.mjs diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..3771806b --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# Bugs + +- Setting the default model does not seem like it works. + +# UX + +- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff +- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex. +- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live +- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card. +- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them. +- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time. +- Typing in the text area of the plan mode was super laggy. +- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something. +- modals are not scrollable if height of the screen is small enough +- and the Agent Runner add an archival button for the new sessions. +- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue. diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 50380095..c14ab6d0 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { RouterProvider } from '@tanstack/react-router'; import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; @@ -15,6 +15,17 @@ export default function App() { return true; }); + // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode + // React's internal scheduler creates performance marks/measures that accumulate without cleanup + useEffect(() => { + const clearPerfEntries = () => { + performance.clearMarks(); + performance.clearMeasures(); + }; + const interval = setInterval(clearPerfEntries, 5000); + return () => clearInterval(interval); + }, []); + // Run settings migration on startup (localStorage -> file storage) const migrationState = useSettingsMigration(); if (migrationState.migrated) { diff --git a/package.json b/package.json index bb9c7efa..e3364964 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", "dev": "node init.mjs", + "start": "node start.mjs", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", "_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui", diff --git a/start.mjs b/start.mjs new file mode 100755 index 00000000..4d5153fd --- /dev/null +++ b/start.mjs @@ -0,0 +1,722 @@ +#!/usr/bin/env node + +/** + * Automaker - Production Mode Launch Script + * + * This script runs the application in production mode (no dev server). + * It builds everything if needed, then prompts the user to choose web or electron mode. + * + * SECURITY NOTE: This script uses a restricted fs wrapper that only allows + * operations within the script's directory (__dirname). This is a standalone + * launch script that runs before the platform library is available. + */ + +import { execSync } from 'child_process'; +import fsNative from 'fs'; +import http from 'http'; +import path from 'path'; +import readline from 'readline'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const treeKill = require('tree-kill'); +const crossSpawn = require('cross-spawn'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ============================================================================= +// Restricted fs wrapper - only allows operations within __dirname +// ============================================================================= + +/** + * Validate that a path is within the script's directory + * @param {string} targetPath - Path to validate + * @returns {string} - Resolved path if valid + * @throws {Error} - If path is outside __dirname + */ +function validateScriptPath(targetPath) { + const resolved = path.resolve(__dirname, targetPath); + const normalizedBase = path.resolve(__dirname); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error( + `[start.mjs] Security: Path access denied outside script directory: ${targetPath}` + ); + } + return resolved; +} + +/** + * Restricted fs operations - only within script directory + */ +const fs = { + existsSync(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validateScriptPath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validateScriptPath(targetPath); + return fsNative.createWriteStream(validated); + }, +}; + +// Colors for terminal output (works on modern terminals including Windows) +const colors = { + green: '\x1b[0;32m', + blue: '\x1b[0;34m', + yellow: '\x1b[1;33m', + red: '\x1b[0;31m', + reset: '\x1b[0m', +}; + +const isWindows = process.platform === 'win32'; + +// Track background processes for cleanup +let serverProcess = null; +let webProcess = null; +let electronProcess = null; + +/** + * Print colored output + */ +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +/** + * Print the header banner + */ +function printHeader() { + console.log('╔═══════════════════════════════════════════════════════╗'); + console.log('ā•‘ Automaker Production Mode ā•‘'); + console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); + console.log(''); +} + +/** + * Execute a command synchronously and return stdout + */ +function execCommand(command, options = {}) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: 'pipe', + ...options, + }).trim(); + } catch { + return null; + } +} + +/** + * Get process IDs using a specific port (cross-platform) + */ +function getProcessesOnPort(port) { + const pids = new Set(); + + if (isWindows) { + // Windows: Use netstat to find PIDs + try { + const output = execCommand(`netstat -ano | findstr :${port}`); + if (output) { + const lines = output.split('\n'); + for (const line of lines) { + // Match lines with LISTENING or ESTABLISHED on our port + const match = line.match(/:\d+\s+.*?(\d+)\s*$/); + if (match) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.add(pid); + } + } + } + } catch { + // Ignore errors + } + } else { + // Unix: Use lsof + try { + const output = execCommand(`lsof -ti:${port}`); + if (output) { + output.split('\n').forEach((pid) => { + const parsed = parseInt(pid.trim(), 10); + if (parsed > 0) pids.add(parsed); + }); + } + } catch { + // Ignore errors + } + } + + return Array.from(pids); +} + +/** + * Kill a process by PID (cross-platform) + */ +function killProcess(pid) { + try { + if (isWindows) { + execCommand(`taskkill /F /PID ${pid}`); + } else { + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } +} + +/** + * Check if a port is in use (without killing) + */ +function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + +/** + * Kill processes on a port and wait for it to be freed + */ +async function killPort(port) { + const pids = getProcessesOnPort(port); + + if (pids.length === 0) { + log(`āœ“ Port ${port} is available`, 'green'); + return true; + } + + log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); + + for (const pid of pids) { + killProcess(pid); + } + + // Wait for port to be freed (max 5 seconds) + for (let i = 0; i < 10; i++) { + await sleep(500); + const remainingPids = getProcessesOnPort(port); + if (remainingPids.length === 0) { + log(`āœ“ Port ${port} is now free`, 'green'); + return true; + } + } + + log(`Warning: Port ${port} may still be in use`, 'red'); + return false; +} + +/** + * Sleep for a given number of milliseconds + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if the server health endpoint is responding + */ +function checkHealth(port = 3008) { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Prompt the user for input + */ +function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +/** + * Run npm command using cross-spawn for Windows compatibility + */ +function runNpm(args, options = {}) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd: __dirname, + ...restOptions, + // Ensure environment variables are properly merged with process.env + env: { + ...process.env, + ...(env || {}), + }, + }; + // cross-spawn handles Windows .cmd files automatically + return crossSpawn('npm', args, spawnOptions); +} + +/** + * Run an npm command and wait for completion + */ +function runNpmAndWait(args, options = {}) { + const child = runNpm(args, options); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run npx command using cross-spawn for Windows compatibility + */ +function runNpx(args, options = {}) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd: __dirname, + ...restOptions, + // Ensure environment variables are properly merged with process.env + env: { + ...process.env, + ...(env || {}), + }, + }; + // cross-spawn handles Windows .cmd files automatically + return crossSpawn('npx', args, spawnOptions); +} + +/** + * Kill a process tree using tree-kill + */ +function killProcessTree(pid) { + return new Promise((resolve) => { + if (!pid) { + resolve(); + return; + } + treeKill(pid, 'SIGTERM', (err) => { + if (err) { + // Try force kill if graceful termination fails + treeKill(pid, 'SIGKILL', () => resolve()); + } else { + resolve(); + } + }); + }); +} + +/** + * Cleanup function to kill all spawned processes + */ +async function cleanup() { + console.log('\nCleaning up...'); + + const killPromises = []; + + if (serverProcess && !serverProcess.killed && serverProcess.pid) { + killPromises.push(killProcessTree(serverProcess.pid)); + } + + if (webProcess && !webProcess.killed && webProcess.pid) { + killPromises.push(killProcessTree(webProcess.pid)); + } + + if (electronProcess && !electronProcess.killed && electronProcess.pid) { + killPromises.push(killProcessTree(electronProcess.pid)); + } + + await Promise.all(killPromises); +} + +/** + * Check if production builds exist + */ +function checkBuilds() { + const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); + const uiDist = path.join(__dirname, 'apps', 'ui', 'dist'); + const electronDist = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); + + return { + server: fs.existsSync(serverDist), + ui: fs.existsSync(uiDist), + electron: fs.existsSync(electronDist), + }; +} + +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); + + printHeader(); + + // Check if node_modules exists + if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { + log('Installing dependencies...', 'blue'); + const install = runNpm(['install'], { stdio: 'inherit' }); + await new Promise((resolve, reject) => { + install.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install failed with code ${code}`)); + }); + }); + } + + // Always build shared packages first to ensure they're up to date + // (source may have changed even if dist directories exist) + log('Building shared packages...', 'blue'); + try { + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); + log('āœ“ Shared packages built', 'green'); + } catch (error) { + log(`Failed to build shared packages: ${error.message}`, 'red'); + process.exit(1); + } + + // Always rebuild server to ensure it's in sync with packages + log('Building server...', 'blue'); + try { + await runNpmAndWait(['run', 'build'], { stdio: 'inherit', cwd: path.join(__dirname, 'apps', 'server') }); + log('āœ“ Server built', 'green'); + } catch (error) { + log(`Failed to build server: ${error.message}`, 'red'); + process.exit(1); + } + + // Check if UI/Electron builds exist (these are slower, so only build if missing) + const builds = checkBuilds(); + + if (!builds.ui || !builds.electron) { + log('UI/Electron builds not found. Building...', 'yellow'); + console.log(''); + + try { + // Build UI (includes Electron main process) + log('Building UI...', 'blue'); + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }); + + log('āœ“ Build complete!', 'green'); + console.log(''); + } catch (error) { + log(`Build failed: ${error.message}`, 'red'); + process.exit(1); + } + } else { + log('āœ“ UI builds found', 'green'); + console.log(''); + } + + // Check for processes on required ports and prompt user + log('Checking for processes on ports 3007 and 3008...', 'yellow'); + + const webPortInUse = isPortInUse(3007); + const serverPortInUse = isPortInUse(3008); + + let webPort = 3007; + let serverPort = 3008; + let corsOriginEnv = process.env.CORS_ORIGIN || ''; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(3007); + log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(3008); + log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(3007); + } else { + log(`āœ“ Port 3007 is available`, 'green'); + } + if (serverPortInUse) { + await killPort(3008); + } else { + log(`āœ“ Port 3008 is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + // Prompt for new ports + while (true) { + const newWebPort = await prompt('Enter web port (default 3007): '); + const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; + + if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (isPortInUse(parsedWebPort)) { + const pids = getProcessesOnPort(parsedWebPort); + log( + `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + webPort = parsedWebPort; + break; + } + + while (true) { + const newServerPort = await prompt('Enter server port (default 3008): '); + const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; + + if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (parsedServerPort === webPort) { + log('Server port cannot be the same as web port.', 'red'); + continue; + } + + if (isPortInUse(parsedServerPort)) { + const pids = getProcessesOnPort(parsedServerPort); + log( + `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, + 'red' + ); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + serverPort = parsedServerPort; + break; + } + + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`āœ“ Port 3007 is available`, 'green'); + log(`āœ“ Port 3008 is available`, 'green'); + } + + // Ensure backend CORS allows whichever UI port we ended up using. + { + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + corsOriginEnv = Array.from(origins).join(','); + } + console.log(''); + + // Show menu + console.log('═══════════════════════════════════════════════════════'); + console.log(' Select Application Mode:'); + console.log('═══════════════════════════════════════════════════════'); + console.log(' 1) Web Application (Browser)'); + console.log(' 2) Desktop Application (Electron)'); + console.log('═══════════════════════════════════════════════════════'); + console.log(''); + + // Setup cleanup handlers + let cleaningUp = false; + const handleExit = async (signal) => { + if (cleaningUp) return; + cleaningUp = true; + await cleanup(); + process.exit(0); + }; + + process.on('SIGINT', () => handleExit('SIGINT')); + process.on('SIGTERM', () => handleExit('SIGTERM')); + + // Prompt for choice + while (true) { + const choice = await prompt('Enter your choice (1 or 2): '); + + if (choice === '1') { + console.log(''); + log('Launching Web Application (Production Mode)...', 'blue'); + + // Start the backend server in production mode + log(`Starting backend server on port ${serverPort}...`, 'blue'); + + // Create logs directory + if (!fs.existsSync(path.join(__dirname, 'logs'))) { + fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); + } + + // Start server in background, showing output in console AND logging to file + const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); + serverProcess = runNpm(['run', 'start'], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: path.join(__dirname, 'apps', 'server'), + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, + }); + + // Pipe to both log file and console + serverProcess.stdout?.on('data', (data) => { + process.stdout.write(data); + logStream.write(data); + }); + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(data); + logStream.write(data); + }); + + log('Waiting for server to be ready...', 'yellow'); + + // Wait for server health check + const maxRetries = 30; + let serverReady = false; + + for (let i = 0; i < maxRetries; i++) { + if (await checkHealth(serverPort)) { + serverReady = true; + break; + } + process.stdout.write('.'); + await sleep(1000); + } + + console.log(''); + + if (!serverReady) { + log('Error: Server failed to start', 'red'); + console.log('Check logs/server.log for details'); + cleanup(); + process.exit(1); + } + + log('āœ“ Server is ready!', 'green'); + log(`Starting web server...`, 'blue'); + + // Start vite preview to serve built static files + webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }); + + log(`The application is available at: http://localhost:${webPort}`, 'green'); + console.log(''); + + await new Promise((resolve) => { + webProcess.on('close', resolve); + }); + + break; + } else if (choice === '2') { + console.log(''); + log('Launching Desktop Application (Production Mode)...', 'blue'); + log('(Electron will start its own backend server)', 'yellow'); + console.log(''); + + // Run electron directly with the built main.js + const electronMainPath = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); + + if (!fs.existsSync(electronMainPath)) { + log('Error: Electron main process not built. Run build first.', 'red'); + process.exit(1); + } + + // Start vite preview to serve built static files for electron + // (Electron in non-packaged mode needs a server to load from) + log('Starting static file server...', 'blue'); + webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }); + + // Wait a moment for vite preview to start + await sleep(2000); + + // Use electron from node_modules + electronProcess = runNpx(['electron', electronMainPath], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + NODE_ENV: 'production', + }, + }); + + await new Promise((resolve) => { + electronProcess.on('close', () => { + // Also kill vite preview when electron closes + if (webProcess && !webProcess.killed && webProcess.pid) { + killProcessTree(webProcess.pid); + } + resolve(); + }); + }); + + break; + } else { + log('Invalid choice. Please enter 1 or 2.', 'red'); + } + } +} + +// Run main function +main().catch((err) => { + console.error(err); + cleanup(); + process.exit(1); +}); From e32a82cca5442bcc5a814781f08cf34086b7cfa4 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:00:20 -0500 Subject: [PATCH 07/17] refactor: remove MCP permission settings and streamline SDK options for autonomous mode - Removed MCP permission settings from the application, including related functions and UI components. - Updated SDK options to always bypass permissions and allow unrestricted tool access in autonomous mode. - Adjusted related components and services to reflect the removal of MCP permission configurations, ensuring a cleaner and more efficient codebase. --- apps/server/src/lib/sdk-options.ts | 48 ++++------ apps/server/src/lib/settings-helpers.ts | 35 ------- apps/server/src/providers/claude-provider.ts | 22 ++--- .../routes/enhance-prompt/routes/enhance.ts | 4 +- .../routes/features/routes/generate-title.ts | 4 +- apps/server/src/services/agent-service.ts | 8 -- apps/server/src/services/auto-mode-service.ts | 14 --- .../tests/unit/lib/settings-helpers.test.ts | 91 +----------------- .../mcp-servers/components/index.ts | 1 - .../components/mcp-permission-settings.tsx | 96 ------------------- .../mcp-servers/hooks/use-mcp-servers.ts | 15 +-- .../mcp-servers/mcp-servers-section.tsx | 20 +--- apps/ui/src/hooks/use-settings-migration.ts | 6 +- apps/ui/src/lib/http-api-client.ts | 2 - apps/ui/src/store/app-store.ts | 21 ---- libs/types/src/provider.ts | 2 - libs/types/src/settings.ts | 8 -- 17 files changed, 36 insertions(+), 361 deletions(-) delete mode 100644 apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index d9b78398..59aa4c60 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -252,10 +252,14 @@ export function getModelForUseCase( /** * Base options that apply to all SDK calls + * + * AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations + * for fully autonomous operation without user prompts. */ function getBaseOptions(): Partial { return { - permissionMode: 'acceptEdits', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }; } @@ -276,31 +280,27 @@ interface McpPermissionOptions { * Centralizes the logic for determining permission modes and tool restrictions * when MCP servers are configured. * + * AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation. + * Always allow unrestricted tools when MCP servers are configured. + * * @param config - The SDK options config * @returns Object with MCP permission settings to spread into final options */ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const mcpAutoApprove = config.mcpAutoApproveTools ?? true; - const mcpUnrestricted = config.mcpUnrestrictedTools ?? true; - // Determine if we should bypass permissions based on settings - const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; - // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) - const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; + // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools + // Only restrict tools when no MCP servers are configured + const shouldRestrictTools = !hasMcpServers; return { shouldRestrictTools, - // Only include bypass options when MCP is configured and auto-approve is enabled - bypassOptions: shouldBypassPermissions - ? { - permissionMode: 'bypassPermissions' as const, - // Required flag when using bypassPermissions mode - allowDangerouslySkipPermissions: true, - } - : {}, + // AUTONOMOUS MODE: Always include bypass options (though base options already set this) + bypassOptions: { + permissionMode: 'bypassPermissions' as const, + // Required flag when using bypassPermissions mode + allowDangerouslySkipPermissions: true, + }, // Include MCP servers if configured mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, }; @@ -392,12 +392,6 @@ export interface CreateSdkOptionsConfig { /** MCP servers to make available to the agent */ mcpServers?: Record; - - /** Auto-approve MCP tool calls without permission prompts */ - mcpAutoApproveTools?: boolean; - - /** Allow unrestricted tools when MCP servers are enabled */ - mcpUnrestrictedTools?: boolean; } // Re-export MCP types from @automaker/types for convenience @@ -426,10 +420,7 @@ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Opt return { ...getBaseOptions(), - // Override permissionMode - spec generation only needs read-only tools - // Using "acceptEdits" can cause Claude to write files to unexpected locations - // See: https://github.com/AutoMaker-Org/automaker/issues/149 - permissionMode: 'default', + // AUTONOMOUS MODE: Base options already set bypassPermissions and allowDangerouslySkipPermissions model: getModelForUseCase('spec', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, @@ -458,8 +449,7 @@ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): return { ...getBaseOptions(), - // Override permissionMode - feature generation only needs read-only tools - permissionMode: 'default', + // AUTONOMOUS MODE: Base options already set bypassPermissions and allowDangerouslySkipPermissions model: getModelForUseCase('features', config.model), maxTurns: MAX_TURNS.quick, cwd: config.cwd, diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index b6e86ff2..9a322994 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -191,41 +191,6 @@ export async function getMCPServersFromSettings( } } -/** - * Get MCP permission settings from global settings. - * - * @param settingsService - Optional settings service instance - * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') - * @returns Promise resolving to MCP permission settings - */ -export async function getMCPPermissionSettings( - settingsService?: SettingsService | null, - logPrefix = '[SettingsHelper]' -): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> { - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true }; - - if (!settingsService) { - return defaults; - } - - try { - const globalSettings = await settingsService.getGlobalSettings(); - const result = { - mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true, - mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true, - }; - logger.info( - `${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}` - ); - return result; - } catch (error) { - logger.error(`${logPrefix} Failed to load MCP permission settings:`, error); - return defaults; - } -} - /** * Convert a settings MCPServerConfig to SDK McpServerConfig format. * Validates required fields and throws informative errors if missing. diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..f61db202 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -63,20 +63,13 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options - // MCP permission logic - determines how to handle tool permissions when MCP servers are configured. - // This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since - // the provider is the final point where SDK options are constructed. + // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - const mcpAutoApprove = options.mcpAutoApproveTools ?? true; - const mcpUnrestricted = options.mcpUnrestrictedTools ?? true; const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; - // Determine permission mode based on settings - const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; - // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) - const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; + // AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools + // Only restrict tools when no MCP servers are configured + const shouldRestrictTools = !hasMcpServers; const sdkOptions: Options = { model, @@ -88,10 +81,9 @@ export class ClaudeProvider extends BaseProvider { // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) ...(allowedTools && shouldRestrictTools && { allowedTools }), ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), - // When MCP servers are configured and auto-approve is enabled, use bypassPermissions - permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', - // Required when using bypassPermissions mode - ...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }), + // AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, abortController, // Resume existing SDK session if we have a session ID ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index ad6e9602..744a67b0 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -164,7 +164,9 @@ export function createEnhanceHandler( systemPrompt, maxTurns: 1, allowedTools: [], - permissionMode: 'acceptEdits', + // AUTONOMOUS MODE: Always bypass permissions + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }, }); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 1225a825..49c59801 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -96,7 +96,9 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P systemPrompt: SYSTEM_PROMPT, maxTurns: 1, allowedTools: [], - permissionMode: 'acceptEdits', + // AUTONOMOUS MODE: Always bypass permissions + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }, }); diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index c507d81b..6fbe7744 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -23,7 +23,6 @@ import { getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, - getMCPPermissionSettings, getPromptCustomization, } from '../lib/settings-helpers.js'; @@ -235,9 +234,6 @@ export class AgentService { // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); - // Load MCP permission settings (global setting only) - const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]'); - // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -264,8 +260,6 @@ export class AgentService { autoLoadClaudeMd, enableSandboxMode, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -290,8 +284,6 @@ export class AgentService { sandbox: sdkOptions.sandbox, // Pass sandbox configuration sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 54f2f8f1..a4e62778 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -38,7 +38,6 @@ import { getEnableSandboxModeSetting, filterClaudeMdFromContext, getMCPServersFromSettings, - getMCPPermissionSettings, getPromptCustomization, } from '../lib/settings-helpers.js'; @@ -2003,9 +2002,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Load MCP servers from settings (global setting only) const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); - // Load MCP permission settings (global setting only) - const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]'); - // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, @@ -2014,8 +2010,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. autoLoadClaudeMd, enableSandboxMode, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -2058,8 +2052,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Execute via provider @@ -2291,8 +2283,6 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let revisionText = ''; @@ -2431,8 +2421,6 @@ After generating the revised spec, output: allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let taskOutput = ''; @@ -2523,8 +2511,6 @@ Implement all the changes described in the plan above.`; allowedTools: allowedTools, abortController, mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, - mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, - mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); for await (const msg of continuationStream) { diff --git a/apps/server/tests/unit/lib/settings-helpers.test.ts b/apps/server/tests/unit/lib/settings-helpers.test.ts index 8af48580..a7096c55 100644 --- a/apps/server/tests/unit/lib/settings-helpers.test.ts +++ b/apps/server/tests/unit/lib/settings-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js'; +import { getMCPServersFromSettings } from '@/lib/settings-helpers.js'; import type { SettingsService } from '@/services/settings-service.js'; // Mock the logger @@ -286,93 +286,4 @@ describe('settings-helpers.ts', () => { }); }); }); - - describe('getMCPPermissionSettings', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return defaults when settingsService is null', async () => { - const result = await getMCPPermissionSettings(null); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should return defaults when settingsService is undefined', async () => { - const result = await getMCPPermissionSettings(undefined); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should return settings from service', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: false, - mcpUnrestrictedTools: false, - }), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: false, - mcpUnrestrictedTools: false, - }); - }); - - it('should default to true when settings are undefined', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({}), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - }); - - it('should handle mixed settings', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: false, - }), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: false, - }); - }); - - it('should return defaults and log error on exception', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), - } as unknown as SettingsService; - - const result = await getMCPPermissionSettings(mockSettingsService, '[Test]'); - expect(result).toEqual({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }); - // Logger will be called with error, but we don't need to assert it - }); - - it('should use custom log prefix', async () => { - const mockSettingsService = { - getGlobalSettings: vi.fn().mockResolvedValue({ - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, - }), - } as unknown as SettingsService; - - await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]'); - // Logger will be called with custom prefix, but we don't need to assert it - }); - }); }); diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts index db49d81d..6903ba40 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts @@ -1,4 +1,3 @@ export { MCPServerHeader } from './mcp-server-header'; -export { MCPPermissionSettings } from './mcp-permission-settings'; export { MCPToolsWarning } from './mcp-tools-warning'; export { MCPServerCard } from './mcp-server-card'; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx deleted file mode 100644 index e65e25bb..00000000 --- a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { ShieldAlert } from 'lucide-react'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { syncSettingsToServer } from '@/hooks/use-settings-migration'; -import { cn } from '@/lib/utils'; - -interface MCPPermissionSettingsProps { - mcpAutoApproveTools: boolean; - mcpUnrestrictedTools: boolean; - onAutoApproveChange: (checked: boolean) => void; - onUnrestrictedChange: (checked: boolean) => void; -} - -export function MCPPermissionSettings({ - mcpAutoApproveTools, - mcpUnrestrictedTools, - onAutoApproveChange, - onUnrestrictedChange, -}: MCPPermissionSettingsProps) { - const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools; - - return ( -
-
-
- { - onAutoApproveChange(checked); - await syncSettingsToServer(); - }} - data-testid="mcp-auto-approve-toggle" - className="mt-0.5" - /> -
- -

- When enabled, the AI agent can use MCP tools without permission prompts. -

- {mcpAutoApproveTools && ( -

- - Bypasses normal permission checks -

- )} -
-
- -
- { - onUnrestrictedChange(checked); - await syncSettingsToServer(); - }} - data-testid="mcp-unrestricted-toggle" - className="mt-0.5" - /> -
- -

- When enabled, the AI agent can use any tool, not just the default set. -

- {mcpUnrestrictedTools && ( -

- - Agent has full tool access including file writes and bash -

- )} -
-
- - {hasAnyEnabled && ( -
-

Security Note

-

- These settings reduce security restrictions for MCP tool usage. Only enable if you - trust all configured MCP servers. -

-
- )} -
-
- ); -} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts index a6cd83b4..615aa657 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts +++ b/apps/ui/src/components/views/settings-view/mcp-servers/hooks/use-mcp-servers.ts @@ -21,16 +21,7 @@ interface PendingServerData { } export function useMCPServers() { - const { - mcpServers, - addMCPServer, - updateMCPServer, - removeMCPServer, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, - } = useAppStore(); + const { mcpServers, addMCPServer, updateMCPServer, removeMCPServer } = useAppStore(); // State const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -938,10 +929,6 @@ export function useMCPServers() { return { // Store state mcpServers, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, // Dialog state isAddDialogOpen, diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx index 0cec3af4..5c06adbe 100644 --- a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx +++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx @@ -1,12 +1,7 @@ import { Plug } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useMCPServers } from './hooks'; -import { - MCPServerHeader, - MCPPermissionSettings, - MCPToolsWarning, - MCPServerCard, -} from './components'; +import { MCPServerHeader, MCPToolsWarning, MCPServerCard } from './components'; import { AddEditServerDialog, DeleteServerDialog, @@ -20,10 +15,6 @@ export function MCPServersSection() { const { // Store state mcpServers, - mcpAutoApproveTools, - mcpUnrestrictedTools, - setMcpAutoApproveTools, - setMcpUnrestrictedTools, // Dialog state isAddDialogOpen, @@ -98,15 +89,6 @@ export function MCPServersSection() { onAdd={handleOpenAddDialog} /> - {mcpServers.length > 0 && ( - - )} - {showToolsWarning && }
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 7abc86c2..3f7df977 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -230,8 +230,6 @@ export async function syncSettingsToServer(): Promise { keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, - mcpAutoApproveTools: state.mcpAutoApproveTools, - mcpUnrestrictedTools: state.mcpUnrestrictedTools, promptCustomization: state.promptCustomization, projects: state.projects, trashedProjects: state.trashedProjects, @@ -336,12 +334,10 @@ export async function loadMCPServersFromServer(): Promise { } const mcpServers = result.settings.mcpServers || []; - const mcpAutoApproveTools = result.settings.mcpAutoApproveTools ?? true; - const mcpUnrestrictedTools = result.settings.mcpUnrestrictedTools ?? true; // Clear existing and add all from server // We need to update the store directly since we can't use hooks here - useAppStore.setState({ mcpServers, mcpAutoApproveTools, mcpUnrestrictedTools }); + useAppStore.setState({ mcpServers }); console.log(`[Settings Load] Loaded ${mcpServers.length} MCP servers from server`); return true; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 32bd88f8..93ed4317 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1438,8 +1438,6 @@ export class HttpApiClient implements ElectronAPI { headers?: Record; enabled?: boolean; }>; - mcpAutoApproveTools?: boolean; - mcpUnrestrictedTools?: boolean; }; error?: string; }> => this.get('/api/settings/global'), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index a57e4d93..ac0ba291 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -491,8 +491,6 @@ export interface AppState { // MCP Servers mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use - mcpAutoApproveTools: boolean; // Auto-approve MCP tool calls without permission prompts - mcpUnrestrictedTools: boolean; // Allow unrestricted tools when MCP servers are enabled // Prompt Customization promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement @@ -777,8 +775,6 @@ export interface AppActions { setAutoLoadClaudeMd: (enabled: boolean) => Promise; setEnableSandboxMode: (enabled: boolean) => Promise; setSkipSandboxWarning: (skip: boolean) => Promise; - setMcpAutoApproveTools: (enabled: boolean) => Promise; - setMcpUnrestrictedTools: (enabled: boolean) => Promise; // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; @@ -980,8 +976,6 @@ const initialState: AppState = { enableSandboxMode: false, // Default to disabled (can be enabled for additional security) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default - mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools - mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled promptCustomization: {}, // Empty by default - all prompts use built-in defaults aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, @@ -1632,19 +1626,6 @@ export const useAppStore = create()( const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, - setMcpAutoApproveTools: async (enabled) => { - set({ mcpAutoApproveTools: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - setMcpUnrestrictedTools: async (enabled) => { - set({ mcpUnrestrictedTools: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - // Prompt Customization actions setPromptCustomization: async (customization) => { set({ promptCustomization: customization }); @@ -2933,8 +2914,6 @@ export const useAppStore = create()( skipSandboxWarning: state.skipSandboxWarning, // MCP settings mcpServers: state.mcpServers, - mcpAutoApproveTools: state.mcpAutoApproveTools, - mcpUnrestrictedTools: state.mcpUnrestrictedTools, // Prompt customization promptCustomization: state.promptCustomization, // Profiles and sessions diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 917b8491..c053da31 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -71,8 +71,6 @@ export interface ExecuteOptions { maxTurns?: number; allowedTools?: string[]; mcpServers?: Record; - mcpAutoApproveTools?: boolean; // Auto-approve MCP tool calls without permission prompts - mcpUnrestrictedTools?: boolean; // Allow unrestricted tools when MCP servers are enabled abortController?: AbortController; conversationHistory?: ConversationMessage[]; // Previous messages for context sdkSessionId?: string; // Claude SDK session ID for resuming conversations diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 309703ce..cc4b7f7c 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -359,10 +359,6 @@ export interface GlobalSettings { // MCP Server Configuration /** List of configured MCP servers for agent use */ mcpServers: MCPServerConfig[]; - /** Auto-approve MCP tool calls without permission prompts (uses bypassPermissions mode) */ - mcpAutoApproveTools?: boolean; - /** Allow unrestricted tools when MCP servers are enabled (don't filter allowedTools) */ - mcpUnrestrictedTools?: boolean; // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ @@ -535,10 +531,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { enableSandboxMode: false, skipSandboxWarning: false, mcpServers: [], - // Default to true for autonomous workflow. Security is enforced when adding servers - // via the security warning dialog that explains the risks. - mcpAutoApproveTools: true, - mcpUnrestrictedTools: true, }; /** Default credentials (empty strings - user must provide API keys) */ From 9552670d3d02aa5a1530275fb73525009988435e Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:11:18 -0500 Subject: [PATCH 08/17] feat: introduce development mode launch script - Added a new script (dev.mjs) to start the application in development mode with hot reloading using Vite. - The script includes functionality for installing Playwright browsers, resolving port configurations, and launching either a web or desktop application. - Removed the old init.mjs script, which was previously responsible for launching the application. - Updated package.json to reference the new dev.mjs script for the development command. - Introduced a shared utilities module (launcher-utils.mjs) for common functionalities used in both development and production scripts. --- .husky/pre-commit | 35 +- dev.mjs | 183 ++++++++++ init.mjs | 649 ---------------------------------- package.json | 2 +- scripts/launcher-utils.mjs | 647 ++++++++++++++++++++++++++++++++++ start.mjs | 688 +++++++------------------------------ 6 files changed, 980 insertions(+), 1224 deletions(-) create mode 100644 dev.mjs delete mode 100644 init.mjs create mode 100644 scripts/launcher-utils.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..812732d5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,34 @@ -npx lint-staged +#!/usr/bin/env sh + +# Try to load nvm if available (optional - works without it too) +if [ -z "$NVM_DIR" ]; then + # Check for Herd's nvm first (macOS with Herd) + if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm" + # Then check standard nvm location + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + fi +fi + +# Source nvm if found (silently skip if not available) +[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null + +# Load node version from .nvmrc if using nvm (silently skip if nvm not available) +[ -f .nvmrc ] && command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 + +# Ensure common system paths are in PATH (for systems without nvm) +# This helps find node/npm installed via Homebrew, system packages, etc. +export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" + +# Run lint-staged - works with or without nvm +# Prefer npx, fallback to npm exec, both work with system-installed Node.js +if command -v npx >/dev/null 2>&1; then + npx lint-staged +elif command -v npm >/dev/null 2>&1; then + npm exec -- lint-staged +else + echo "Error: Neither npx nor npm found in PATH." + echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)" + exit 1 +fi diff --git a/dev.mjs b/dev.mjs new file mode 100644 index 00000000..f2ad01cc --- /dev/null +++ b/dev.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +/** + * Automaker - Development Mode Launch Script + * + * This script starts the application in development mode with hot reloading. + * It uses Vite dev server for fast HMR during development. + * + * Usage: npm run dev + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +import { + createRestrictedFs, + log, + runNpm, + runNpmAndWait, + printHeader, + printModeMenu, + resolvePortConfiguration, + createCleanupHandler, + setupSignalHandlers, + startServerAndWait, + ensureDependencies, + prompt, +} from './scripts/launcher-utils.mjs'; + +const require = createRequire(import.meta.url); +const crossSpawn = require('cross-spawn'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Create restricted fs for this script's directory +const fs = createRestrictedFs(__dirname, 'dev.mjs'); + +// Track background processes for cleanup +const processes = { + server: null, + web: null, + electron: null, +}; + +/** + * Install Playwright browsers (dev-only dependency) + */ +async function installPlaywrightBrowsers() { + log('Checking Playwright browsers...', 'yellow'); + try { + const exitCode = await new Promise((resolve) => { + const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], { + stdio: 'inherit', + cwd: path.join(__dirname, 'apps', 'ui'), + }); + playwright.on('close', (code) => resolve(code)); + playwright.on('error', () => resolve(1)); + }); + + if (exitCode === 0) { + log('Playwright browsers ready', 'green'); + } else { + log('Playwright installation failed (browser automation may not work)', 'yellow'); + } + } catch { + log('Playwright installation skipped', 'yellow'); + } +} + +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); + + printHeader('Automaker Development Environment'); + + // Ensure dependencies are installed + await ensureDependencies(fs, __dirname); + + // Install Playwright browsers (dev-only) + await installPlaywrightBrowsers(); + + // Resolve port configuration (check/kill/change ports) + const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration(); + + // Show mode selection menu + printModeMenu(); + + // Setup cleanup handlers + const cleanup = createCleanupHandler(processes); + setupSignalHandlers(cleanup); + + // Prompt for choice + while (true) { + const choice = await prompt('Enter your choice (1 or 2): '); + + if (choice === '1') { + console.log(''); + log('Launching Web Application (Development Mode)...', 'blue'); + + // Build shared packages once + log('Building shared packages...', 'blue'); + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname); + + // Start the backend server in dev mode + processes.server = await startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs: ['run', '_dev:server'], + cwd: __dirname, + fs, + baseDir: __dirname, + }); + + if (!processes.server) { + cleanup(); + process.exit(1); + } + + log(`The application will be available at: http://localhost:${webPort}`, 'green'); + console.log(''); + + // Start web app with Vite dev server (HMR enabled) + processes.web = runNpm( + ['run', '_dev:web'], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, + }, + __dirname + ); + + await new Promise((resolve) => { + processes.web.on('close', resolve); + }); + + break; + } else if (choice === '2') { + console.log(''); + log('Launching Desktop Application (Development Mode)...', 'blue'); + log('(Electron will start its own backend server)', 'yellow'); + console.log(''); + + // Pass selected ports through to Vite + Electron backend + processes.electron = runNpm( + ['run', 'dev:electron'], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + }, + }, + __dirname + ); + + await new Promise((resolve) => { + processes.electron.on('close', resolve); + }); + + break; + } else { + log('Invalid choice. Please enter 1 or 2.', 'red'); + } + } +} + +// Run main function +main().catch((err) => { + console.error(err); + const cleanup = createCleanupHandler(processes); + cleanup(); + process.exit(1); +}); diff --git a/init.mjs b/init.mjs deleted file mode 100644 index 49d47fa6..00000000 --- a/init.mjs +++ /dev/null @@ -1,649 +0,0 @@ -#!/usr/bin/env node - -/** - * Automaker - Cross-Platform Development Environment Setup and Launch Script - * - * This script works on Windows, macOS, and Linux. - * - * SECURITY NOTE: This script uses a restricted fs wrapper that only allows - * operations within the script's directory (__dirname). This is a standalone - * launch script that runs before the platform library is available. - */ - -import { execSync } from 'child_process'; -import fsNative from 'fs'; -import http from 'http'; -import path from 'path'; -import readline from 'readline'; -import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; - -const require = createRequire(import.meta.url); -const treeKill = require('tree-kill'); -const crossSpawn = require('cross-spawn'); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// ============================================================================= -// Restricted fs wrapper - only allows operations within __dirname -// ============================================================================= - -/** - * Validate that a path is within the script's directory - * @param {string} targetPath - Path to validate - * @returns {string} - Resolved path if valid - * @throws {Error} - If path is outside __dirname - */ -function validateScriptPath(targetPath) { - const resolved = path.resolve(__dirname, targetPath); - const normalizedBase = path.resolve(__dirname); - if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { - throw new Error( - `[init.mjs] Security: Path access denied outside script directory: ${targetPath}` - ); - } - return resolved; -} - -/** - * Restricted fs operations - only within script directory - */ -const fs = { - existsSync(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.existsSync(validated); - }, - mkdirSync(targetPath, options) { - const validated = validateScriptPath(targetPath); - return fsNative.mkdirSync(validated, options); - }, - createWriteStream(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.createWriteStream(validated); - }, -}; - -// Colors for terminal output (works on modern terminals including Windows) -const colors = { - green: '\x1b[0;32m', - blue: '\x1b[0;34m', - yellow: '\x1b[1;33m', - red: '\x1b[0;31m', - reset: '\x1b[0m', -}; - -const isWindows = process.platform === 'win32'; - -// Track background processes for cleanup -let serverProcess = null; -let webProcess = null; -let electronProcess = null; - -/** - * Print colored output - */ -function log(message, color = 'reset') { - console.log(`${colors[color]}${message}${colors.reset}`); -} - -/** - * Print the header banner - */ -function printHeader() { - console.log('╔═══════════════════════════════════════════════════════╗'); - console.log('ā•‘ Automaker Development Environment ā•‘'); - console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); - console.log(''); -} - -/** - * Execute a command synchronously and return stdout - */ -function execCommand(command, options = {}) { - try { - return execSync(command, { - encoding: 'utf8', - stdio: 'pipe', - ...options, - }).trim(); - } catch { - return null; - } -} - -/** - * Get process IDs using a specific port (cross-platform) - */ -function getProcessesOnPort(port) { - const pids = new Set(); - - if (isWindows) { - // Windows: Use netstat to find PIDs - try { - const output = execCommand(`netstat -ano | findstr :${port}`); - if (output) { - const lines = output.split('\n'); - for (const line of lines) { - // Match lines with LISTENING or ESTABLISHED on our port - const match = line.match(/:\d+\s+.*?(\d+)\s*$/); - if (match) { - const pid = parseInt(match[1], 10); - if (pid > 0) pids.add(pid); - } - } - } - } catch { - // Ignore errors - } - } else { - // Unix: Use lsof - try { - const output = execCommand(`lsof -ti:${port}`); - if (output) { - output.split('\n').forEach((pid) => { - const parsed = parseInt(pid.trim(), 10); - if (parsed > 0) pids.add(parsed); - }); - } - } catch { - // Ignore errors - } - } - - return Array.from(pids); -} - -/** - * Kill a process by PID (cross-platform) - */ -function killProcess(pid) { - try { - if (isWindows) { - execCommand(`taskkill /F /PID ${pid}`); - } else { - process.kill(pid, 'SIGKILL'); - } - return true; - } catch { - return false; - } -} - -/** - * Check if a port is in use (without killing) - */ -function isPortInUse(port) { - const pids = getProcessesOnPort(port); - return pids.length > 0; -} - -/** - * Kill processes on a port and wait for it to be freed - */ -async function killPort(port) { - const pids = getProcessesOnPort(port); - - if (pids.length === 0) { - log(`āœ“ Port ${port} is available`, 'green'); - return true; - } - - log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); - - for (const pid of pids) { - killProcess(pid); - } - - // Wait for port to be freed (max 5 seconds) - for (let i = 0; i < 10; i++) { - await sleep(500); - const remainingPids = getProcessesOnPort(port); - if (remainingPids.length === 0) { - log(`āœ“ Port ${port} is now free`, 'green'); - return true; - } - } - - log(`Warning: Port ${port} may still be in use`, 'red'); - return false; -} - -/** - * Sleep for a given number of milliseconds - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Check if the server health endpoint is responding - */ -function checkHealth(port = 3008) { - return new Promise((resolve) => { - const req = http.get(`http://localhost:${port}/api/health`, (res) => { - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); - }); - }); -} - -/** - * Prompt the user for input - */ -function prompt(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -/** - * Run npm command using cross-spawn for Windows compatibility - */ -function runNpm(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npm', args, spawnOptions); -} - -/** - * Run an npm command and wait for completion - */ -function runNpmAndWait(args, options = {}) { - const child = runNpm(args, options); - return new Promise((resolve, reject) => { - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -/** - * Run npx command using cross-spawn for Windows compatibility - */ -function runNpx(args, options = {}) { - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...options, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npx', args, spawnOptions); -} - -/** - * Kill a process tree using tree-kill - */ -function killProcessTree(pid) { - return new Promise((resolve) => { - if (!pid) { - resolve(); - return; - } - treeKill(pid, 'SIGTERM', (err) => { - if (err) { - // Try force kill if graceful termination fails - treeKill(pid, 'SIGKILL', () => resolve()); - } else { - resolve(); - } - }); - }); -} - -/** - * Cleanup function to kill all spawned processes - */ -async function cleanup() { - console.log('\nCleaning up...'); - - const killPromises = []; - - if (serverProcess && !serverProcess.killed && serverProcess.pid) { - killPromises.push(killProcessTree(serverProcess.pid)); - } - - if (webProcess && !webProcess.killed && webProcess.pid) { - killPromises.push(killProcessTree(webProcess.pid)); - } - - if (electronProcess && !electronProcess.killed && electronProcess.pid) { - killPromises.push(killProcessTree(electronProcess.pid)); - } - - await Promise.all(killPromises); -} - -/** - * Main function - */ -async function main() { - // Change to script directory - process.chdir(__dirname); - - printHeader(); - - // Check if node_modules exists - if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { - log('Installing dependencies...', 'blue'); - const install = runNpm(['install'], { stdio: 'inherit' }); - await new Promise((resolve, reject) => { - install.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm install failed with code ${code}`)); - }); - }); - } - - // Install Playwright browsers from apps/ui where @playwright/test is installed - log('Checking Playwright browsers...', 'yellow'); - try { - const exitCode = await new Promise((resolve) => { - const playwright = crossSpawn('npx', ['playwright', 'install', 'chromium'], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - }); - playwright.on('close', (code) => resolve(code)); - playwright.on('error', () => resolve(1)); - }); - - if (exitCode === 0) { - log('Playwright browsers ready', 'green'); - } else { - log('Playwright installation failed (browser automation may not work)', 'yellow'); - } - } catch { - log('Playwright installation skipped', 'yellow'); - } - - // Check for processes on required ports and prompt user - log('Checking for processes on ports 3007 and 3008...', 'yellow'); - - const webPortInUse = isPortInUse(3007); - const serverPortInUse = isPortInUse(3008); - - let webPort = 3007; - let serverPort = 3008; - let corsOriginEnv = process.env.CORS_ORIGIN || ''; - - if (webPortInUse || serverPortInUse) { - console.log(''); - if (webPortInUse) { - const pids = getProcessesOnPort(3007); - log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - if (serverPortInUse) { - const pids = getProcessesOnPort(3008); - log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - console.log(''); - - while (true) { - const choice = await prompt( - 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' - ); - const lowerChoice = choice.toLowerCase(); - - if (lowerChoice === 'k' || lowerChoice === 'kill') { - if (webPortInUse) { - await killPort(3007); - } else { - log(`āœ“ Port 3007 is available`, 'green'); - } - if (serverPortInUse) { - await killPort(3008); - } else { - log(`āœ“ Port 3008 is available`, 'green'); - } - break; - } else if (lowerChoice === 'u' || lowerChoice === 'use') { - // Prompt for new ports - while (true) { - const newWebPort = await prompt('Enter web port (default 3007): '); - const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; - - if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (isPortInUse(parsedWebPort)) { - const pids = getProcessesOnPort(parsedWebPort); - log( - `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - webPort = parsedWebPort; - break; - } - - while (true) { - const newServerPort = await prompt('Enter server port (default 3008): '); - const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; - - if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (parsedServerPort === webPort) { - log('Server port cannot be the same as web port.', 'red'); - continue; - } - - if (isPortInUse(parsedServerPort)) { - const pids = getProcessesOnPort(parsedServerPort); - log( - `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - serverPort = parsedServerPort; - break; - } - - log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); - break; - } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { - log('Cancelled.', 'yellow'); - process.exit(0); - } else { - log( - 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', - 'red' - ); - } - } - } else { - log(`āœ“ Port 3007 is available`, 'green'); - log(`āœ“ Port 3008 is available`, 'green'); - } - - // Ensure backend CORS allows whichever UI port we ended up using. - // If CORS_ORIGIN is set, server enforces it strictly (see apps/server/src/index.ts), - // so we must include the selected web origin(s) in that list. - { - const existing = (process.env.CORS_ORIGIN || '') - .split(',') - .map((o) => o.trim()) - .filter(Boolean) - .filter((o) => o !== '*'); - const origins = new Set(existing); - origins.add(`http://localhost:${webPort}`); - origins.add(`http://127.0.0.1:${webPort}`); - corsOriginEnv = Array.from(origins).join(','); - } - console.log(''); - - // Show menu - console.log('═══════════════════════════════════════════════════════'); - console.log(' Select Application Mode:'); - console.log('═══════════════════════════════════════════════════════'); - console.log(' 1) Web Application (Browser)'); - console.log(' 2) Desktop Application (Electron)'); - console.log('═══════════════════════════════════════════════════════'); - console.log(''); - - // Setup cleanup handlers - let cleaningUp = false; - const handleExit = async (signal) => { - if (cleaningUp) return; - cleaningUp = true; - await cleanup(); - process.exit(0); - }; - - process.on('SIGINT', () => handleExit('SIGINT')); - process.on('SIGTERM', () => handleExit('SIGTERM')); - - // Prompt for choice - while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); - - if (choice === '1') { - console.log(''); - log('Launching Web Application...', 'blue'); - - // Build shared packages once (dev:server and dev:web both do this at the root level) - log('Building shared packages...', 'blue'); - await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); - - // Start the backend server - log(`Starting backend server on port ${serverPort}...`, 'blue'); - - // Create logs directory - if (!fs.existsSync(path.join(__dirname, 'logs'))) { - fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); - } - - // Start server in background, showing output in console AND logging to file - const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); - serverProcess = runNpm(['run', '_dev:server'], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { - PORT: String(serverPort), - CORS_ORIGIN: corsOriginEnv, - }, - }); - - // Pipe to both log file and console so user can see API key - serverProcess.stdout?.on('data', (data) => { - process.stdout.write(data); - logStream.write(data); - }); - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(data); - logStream.write(data); - }); - - log('Waiting for server to be ready...', 'yellow'); - - // Wait for server health check - const maxRetries = 30; - let serverReady = false; - - for (let i = 0; i < maxRetries; i++) { - if (await checkHealth(serverPort)) { - serverReady = true; - break; - } - process.stdout.write('.'); - await sleep(1000); - } - - console.log(''); - - if (!serverReady) { - log('Error: Server failed to start', 'red'); - console.log('Check logs/server.log for details'); - cleanup(); - process.exit(1); - } - - log('āœ“ Server is ready!', 'green'); - log(`The application will be available at: http://localhost:${webPort}`, 'green'); - console.log(''); - - // Start web app - webProcess = runNpm(['run', '_dev:web'], { - stdio: 'inherit', - env: { - TEST_PORT: String(webPort), - VITE_SERVER_URL: `http://localhost:${serverPort}`, - }, - }); - await new Promise((resolve) => { - webProcess.on('close', resolve); - }); - - break; - } else if (choice === '2') { - console.log(''); - log('Launching Desktop Application...', 'blue'); - log('(Electron will start its own backend server)', 'yellow'); - console.log(''); - - // Pass selected ports through to Vite + Electron backend - // - TEST_PORT controls Vite dev server port (see apps/ui/vite.config.mts) - // - PORT controls backend server port (see apps/server/src/index.ts) - electronProcess = runNpm(['run', 'dev:electron'], { - stdio: 'inherit', - env: { - TEST_PORT: String(webPort), - PORT: String(serverPort), - VITE_SERVER_URL: `http://localhost:${serverPort}`, - CORS_ORIGIN: corsOriginEnv, - }, - }); - await new Promise((resolve) => { - electronProcess.on('close', resolve); - }); - - break; - } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); - } - } -} - -// Run main function -main().catch((err) => { - console.error(err); - cleanup(); - process.exit(1); -}); diff --git a/package.json b/package.json index e3364964..7772c924 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "postinstall": "node -e \"const fs=require('fs');if(process.platform==='darwin'){['darwin-arm64','darwin-x64'].forEach(a=>{const p='node_modules/node-pty/prebuilds/'+a+'/spawn-helper';if(fs.existsSync(p))fs.chmodSync(p,0o755)})}\" && node scripts/fix-lockfile-urls.mjs", "fix:lockfile": "node scripts/fix-lockfile-urls.mjs", - "dev": "node init.mjs", + "dev": "node dev.mjs", "start": "node start.mjs", "_dev:web": "npm run dev:web --workspace=apps/ui", "_dev:electron": "npm run dev:electron --workspace=apps/ui", diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs new file mode 100644 index 00000000..af68e452 --- /dev/null +++ b/scripts/launcher-utils.mjs @@ -0,0 +1,647 @@ +/** + * Shared utilities for Automaker launcher scripts (dev.mjs and start.mjs) + * + * This module contains cross-platform utilities for: + * - Process management (ports, killing processes) + * - Terminal output (colors, logging) + * - npm/npx command execution + * - User prompts + * - Health checks + * + * SECURITY NOTE: Uses a restricted fs wrapper that only allows + * operations within a specified base directory. + */ + +import { execSync } from 'child_process'; +import fsNative from 'fs'; +import http from 'http'; +import path from 'path'; +import readline from 'readline'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const treeKill = require('tree-kill'); +const crossSpawn = require('cross-spawn'); + +// ============================================================================= +// Terminal Colors +// ============================================================================= + +export const colors = { + green: '\x1b[0;32m', + blue: '\x1b[0;34m', + yellow: '\x1b[1;33m', + red: '\x1b[0;31m', + reset: '\x1b[0m', +}; + +export const isWindows = process.platform === 'win32'; + +// ============================================================================= +// Restricted fs wrapper - only allows operations within a base directory +// ============================================================================= + +/** + * Create a restricted fs wrapper for a given base directory + * @param {string} baseDir - The base directory to restrict operations to + * @param {string} scriptName - Name of the calling script for error messages + * @returns {object} - Restricted fs operations + */ +export function createRestrictedFs(baseDir, scriptName = 'launcher') { + const normalizedBase = path.resolve(baseDir); + + function validatePath(targetPath) { + const resolved = path.resolve(baseDir, targetPath); + if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { + throw new Error( + `[${scriptName}] Security: Path access denied outside script directory: ${targetPath}` + ); + } + return resolved; + } + + return { + existsSync(targetPath) { + const validated = validatePath(targetPath); + return fsNative.existsSync(validated); + }, + mkdirSync(targetPath, options) { + const validated = validatePath(targetPath); + return fsNative.mkdirSync(validated, options); + }, + createWriteStream(targetPath) { + const validated = validatePath(targetPath); + return fsNative.createWriteStream(validated); + }, + }; +} + +// ============================================================================= +// Logging +// ============================================================================= + +/** + * Print colored output + * @param {string} message - Message to print + * @param {string} color - Color name (green, blue, yellow, red, reset) + */ +export function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +// ============================================================================= +// Command Execution +// ============================================================================= + +/** + * Execute a command synchronously and return stdout + * @param {string} command - Command to execute + * @param {object} options - execSync options + * @returns {string|null} - Command output or null on error + */ +export function execCommand(command, options = {}) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: 'pipe', + ...options, + }).trim(); + } catch { + return null; + } +} + +/** + * Run npm command using cross-spawn for Windows compatibility + * @param {string[]} args - npm command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {ChildProcess} - Spawned process + */ +export function runNpm(args, options = {}, cwd = process.cwd()) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd, + ...restOptions, + env: { + ...process.env, + ...(env || {}), + }, + }; + return crossSpawn('npm', args, spawnOptions); +} + +/** + * Run an npm command and wait for completion + * @param {string[]} args - npm command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {Promise} + */ +export function runNpmAndWait(args, options = {}, cwd = process.cwd()) { + const child = runNpm(args, options, cwd); + return new Promise((resolve, reject) => { + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); + }); + child.on('error', (err) => reject(err)); + }); +} + +/** + * Run npx command using cross-spawn for Windows compatibility + * @param {string[]} args - npx command arguments + * @param {object} options - spawn options + * @param {string} cwd - Working directory + * @returns {ChildProcess} - Spawned process + */ +export function runNpx(args, options = {}, cwd = process.cwd()) { + const { env, ...restOptions } = options; + const spawnOptions = { + stdio: 'inherit', + cwd, + ...restOptions, + env: { + ...process.env, + ...(env || {}), + }, + }; + return crossSpawn('npx', args, spawnOptions); +} + +// ============================================================================= +// Process Management +// ============================================================================= + +/** + * Get process IDs using a specific port (cross-platform) + * @param {number} port - Port number to check + * @returns {number[]} - Array of PIDs using the port + */ +export function getProcessesOnPort(port) { + const pids = new Set(); + + if (isWindows) { + try { + const output = execCommand(`netstat -ano | findstr :${port}`); + if (output) { + const lines = output.split('\n'); + for (const line of lines) { + const match = line.match(/:\d+\s+.*?(\d+)\s*$/); + if (match) { + const pid = parseInt(match[1], 10); + if (pid > 0) pids.add(pid); + } + } + } + } catch { + // Ignore errors + } + } else { + try { + const output = execCommand(`lsof -ti:${port}`); + if (output) { + output.split('\n').forEach((pid) => { + const parsed = parseInt(pid.trim(), 10); + if (parsed > 0) pids.add(parsed); + }); + } + } catch { + // Ignore errors + } + } + + return Array.from(pids); +} + +/** + * Kill a process by PID (cross-platform) + * @param {number} pid - Process ID to kill + * @returns {boolean} - Whether the kill succeeded + */ +export function killProcess(pid) { + try { + if (isWindows) { + execCommand(`taskkill /F /PID ${pid}`); + } else { + process.kill(pid, 'SIGKILL'); + } + return true; + } catch { + return false; + } +} + +/** + * Check if a port is in use (without killing) + * @param {number} port - Port number to check + * @returns {boolean} - Whether the port is in use + */ +export function isPortInUse(port) { + const pids = getProcessesOnPort(port); + return pids.length > 0; +} + +/** + * Kill processes on a port and wait for it to be freed + * @param {number} port - Port number to free + * @returns {Promise} - Whether the port was freed + */ +export async function killPort(port) { + const pids = getProcessesOnPort(port); + + if (pids.length === 0) { + log(`āœ“ Port ${port} is available`, 'green'); + return true; + } + + log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); + + for (const pid of pids) { + killProcess(pid); + } + + // Wait for port to be freed (max 5 seconds) + for (let i = 0; i < 10; i++) { + await sleep(500); + const remainingPids = getProcessesOnPort(port); + if (remainingPids.length === 0) { + log(`āœ“ Port ${port} is now free`, 'green'); + return true; + } + } + + log(`Warning: Port ${port} may still be in use`, 'red'); + return false; +} + +/** + * Kill a process tree using tree-kill + * @param {number} pid - Root process ID + * @returns {Promise} + */ +export function killProcessTree(pid) { + return new Promise((resolve) => { + if (!pid) { + resolve(); + return; + } + treeKill(pid, 'SIGTERM', (err) => { + if (err) { + treeKill(pid, 'SIGKILL', () => resolve()); + } else { + resolve(); + } + }); + }); +} + +// ============================================================================= +// Utilities +// ============================================================================= + +/** + * Sleep for a given number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if the server health endpoint is responding + * @param {number} port - Server port (default 3008) + * @returns {Promise} - Whether the server is healthy + */ +export function checkHealth(port = 3008) { + return new Promise((resolve) => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + resolve(res.statusCode === 200); + }); + req.on('error', () => resolve(false)); + req.setTimeout(2000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Prompt the user for input + * @param {string} question - Question to ask + * @returns {Promise} - User's answer + */ +export function prompt(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ============================================================================= +// Port Configuration Flow +// ============================================================================= + +/** + * Check ports and prompt user for resolution if in use + * @param {object} options - Configuration options + * @param {number} options.defaultWebPort - Default web port (3007) + * @param {number} options.defaultServerPort - Default server port (3008) + * @returns {Promise<{webPort: number, serverPort: number, corsOriginEnv: string}>} + */ +export async function resolvePortConfiguration({ + defaultWebPort = 3007, + defaultServerPort = 3008, +} = {}) { + log(`Checking for processes on ports ${defaultWebPort} and ${defaultServerPort}...`, 'yellow'); + + const webPortInUse = isPortInUse(defaultWebPort); + const serverPortInUse = isPortInUse(defaultServerPort); + + let webPort = defaultWebPort; + let serverPort = defaultServerPort; + + if (webPortInUse || serverPortInUse) { + console.log(''); + if (webPortInUse) { + const pids = getProcessesOnPort(defaultWebPort); + log(`⚠ Port ${defaultWebPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + if (serverPortInUse) { + const pids = getProcessesOnPort(defaultServerPort); + log(`⚠ Port ${defaultServerPort} is in use by process(es): ${pids.join(', ')}`, 'yellow'); + } + console.log(''); + + while (true) { + const choice = await prompt( + 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' + ); + const lowerChoice = choice.toLowerCase(); + + if (lowerChoice === 'k' || lowerChoice === 'kill') { + if (webPortInUse) { + await killPort(defaultWebPort); + } else { + log(`āœ“ Port ${defaultWebPort} is available`, 'green'); + } + if (serverPortInUse) { + await killPort(defaultServerPort); + } else { + log(`āœ“ Port ${defaultServerPort} is available`, 'green'); + } + break; + } else if (lowerChoice === 'u' || lowerChoice === 'use') { + webPort = await promptForPort('web', defaultWebPort); + serverPort = await promptForPort('server', defaultServerPort, webPort); + log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); + break; + } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { + log('Cancelled.', 'yellow'); + process.exit(0); + } else { + log( + 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', + 'red' + ); + } + } + } else { + log(`āœ“ Port ${defaultWebPort} is available`, 'green'); + log(`āœ“ Port ${defaultServerPort} is available`, 'green'); + } + + // Build CORS origin env + const existing = (process.env.CORS_ORIGIN || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => o !== '*'); + const origins = new Set(existing); + origins.add(`http://localhost:${webPort}`); + origins.add(`http://127.0.0.1:${webPort}`); + const corsOriginEnv = Array.from(origins).join(','); + + console.log(''); + + return { webPort, serverPort, corsOriginEnv }; +} + +/** + * Prompt for a specific port with validation + * @param {string} name - Port name (web/server) + * @param {number} defaultPort - Default port value + * @param {number} excludePort - Port to exclude (optional) + * @returns {Promise} + */ +async function promptForPort(name, defaultPort, excludePort = null) { + while (true) { + const input = await prompt(`Enter ${name} port (default ${defaultPort}): `); + const parsed = input.trim() ? parseInt(input.trim(), 10) : defaultPort; + + if (isNaN(parsed) || parsed < 1024 || parsed > 65535) { + log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); + continue; + } + + if (excludePort && parsed === excludePort) { + log(`${name} port cannot be the same as the other port.`, 'red'); + continue; + } + + if (isPortInUse(parsed)) { + const pids = getProcessesOnPort(parsed); + log(`Port ${parsed} is already in use by process(es): ${pids.join(', ')}`, 'red'); + const useAnyway = await prompt('Use this port anyway? (y/n): '); + if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { + continue; + } + } + + return parsed; + } +} + +// ============================================================================= +// UI Components +// ============================================================================= + +/** + * Print the application header banner + * @param {string} title - Header title + */ +export function printHeader(title) { + console.log('╔═══════════════════════════════════════════════════════╗'); + console.log(`ā•‘ ${title.padEnd(45)}ā•‘`); + console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); + console.log(''); +} + +/** + * Print the application mode menu + */ +export function printModeMenu() { + console.log('═══════════════════════════════════════════════════════'); + console.log(' Select Application Mode:'); + console.log('═══════════════════════════════════════════════════════'); + console.log(' 1) Web Application (Browser)'); + console.log(' 2) Desktop Application (Electron)'); + console.log('═══════════════════════════════════════════════════════'); + console.log(''); +} + +// ============================================================================= +// Process Cleanup +// ============================================================================= + +/** + * Create a cleanup handler for spawned processes + * @param {object} processes - Object with process references {server, web, electron} + * @returns {Function} - Cleanup function + */ +export function createCleanupHandler(processes) { + return async function cleanup() { + console.log('\nCleaning up...'); + + const killPromises = []; + + if (processes.server && !processes.server.killed && processes.server.pid) { + killPromises.push(killProcessTree(processes.server.pid)); + } + + if (processes.web && !processes.web.killed && processes.web.pid) { + killPromises.push(killProcessTree(processes.web.pid)); + } + + if (processes.electron && !processes.electron.killed && processes.electron.pid) { + killPromises.push(killProcessTree(processes.electron.pid)); + } + + await Promise.all(killPromises); + }; +} + +/** + * Setup signal handlers for graceful shutdown + * @param {Function} cleanup - Cleanup function + */ +export function setupSignalHandlers(cleanup) { + let cleaningUp = false; + + const handleExit = async () => { + if (cleaningUp) return; + cleaningUp = true; + await cleanup(); + process.exit(0); + }; + + process.on('SIGINT', () => handleExit()); + process.on('SIGTERM', () => handleExit()); +} + +// ============================================================================= +// Server Startup +// ============================================================================= + +/** + * Start the backend server and wait for it to be ready + * @param {object} options - Configuration options + * @returns {Promise} - Server process + */ +export async function startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs, + cwd, + fs, + baseDir, +}) { + log(`Starting backend server on port ${serverPort}...`, 'blue'); + + // Create logs directory + const logsDir = path.join(baseDir, 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const logStream = fs.createWriteStream(path.join(baseDir, 'logs', 'server.log')); + const serverProcess = runNpm( + npmArgs, + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + PORT: String(serverPort), + CORS_ORIGIN: corsOriginEnv, + }, + }, + cwd + ); + + // Pipe to both log file and console + serverProcess.stdout?.on('data', (data) => { + process.stdout.write(data); + logStream.write(data); + }); + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(data); + logStream.write(data); + }); + + log('Waiting for server to be ready...', 'yellow'); + + // Wait for server health check + const maxRetries = 30; + let serverReady = false; + + for (let i = 0; i < maxRetries; i++) { + if (await checkHealth(serverPort)) { + serverReady = true; + break; + } + process.stdout.write('.'); + await sleep(1000); + } + + console.log(''); + + if (!serverReady) { + log('Error: Server failed to start', 'red'); + console.log('Check logs/server.log for details'); + return null; + } + + log('āœ“ Server is ready!', 'green'); + return serverProcess; +} + +// ============================================================================= +// Dependencies +// ============================================================================= + +/** + * Ensure node_modules exists, install if not + * @param {object} fs - Restricted fs object + * @param {string} baseDir - Base directory + */ +export async function ensureDependencies(fs, baseDir) { + if (!fs.existsSync(path.join(baseDir, 'node_modules'))) { + log('Installing dependencies...', 'blue'); + const install = runNpm(['install'], { stdio: 'inherit' }, baseDir); + await new Promise((resolve, reject) => { + install.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`npm install failed with code ${code}`)); + }); + }); + } +} diff --git a/start.mjs b/start.mjs index 4d5153fd..0992ccfe 100755 --- a/start.mjs +++ b/start.mjs @@ -3,350 +3,55 @@ /** * Automaker - Production Mode Launch Script * - * This script runs the application in production mode (no dev server). - * It builds everything if needed, then prompts the user to choose web or electron mode. + * This script runs the application in production mode (no Vite dev server). + * It builds everything if needed, then serves static files via vite preview. * - * SECURITY NOTE: This script uses a restricted fs wrapper that only allows - * operations within the script's directory (__dirname). This is a standalone - * launch script that runs before the platform library is available. + * Key differences from dev.mjs: + * - Uses pre-built static files instead of Vite dev server (faster startup) + * - No HMR or hot reloading + * - Server runs from compiled dist/ directory + * - Uses "vite preview" to serve static UI files + * + * Usage: npm run start */ -import { execSync } from 'child_process'; -import fsNative from 'fs'; -import http from 'http'; import path from 'path'; -import readline from 'readline'; import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const treeKill = require('tree-kill'); -const crossSpawn = require('cross-spawn'); +import { + createRestrictedFs, + log, + runNpm, + runNpmAndWait, + runNpx, + printHeader, + printModeMenu, + resolvePortConfiguration, + createCleanupHandler, + setupSignalHandlers, + startServerAndWait, + ensureDependencies, + prompt, + killProcessTree, + sleep, +} from './scripts/launcher-utils.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -// ============================================================================= -// Restricted fs wrapper - only allows operations within __dirname -// ============================================================================= - -/** - * Validate that a path is within the script's directory - * @param {string} targetPath - Path to validate - * @returns {string} - Resolved path if valid - * @throws {Error} - If path is outside __dirname - */ -function validateScriptPath(targetPath) { - const resolved = path.resolve(__dirname, targetPath); - const normalizedBase = path.resolve(__dirname); - if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) { - throw new Error( - `[start.mjs] Security: Path access denied outside script directory: ${targetPath}` - ); - } - return resolved; -} - -/** - * Restricted fs operations - only within script directory - */ -const fs = { - existsSync(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.existsSync(validated); - }, - mkdirSync(targetPath, options) { - const validated = validateScriptPath(targetPath); - return fsNative.mkdirSync(validated, options); - }, - createWriteStream(targetPath) { - const validated = validateScriptPath(targetPath); - return fsNative.createWriteStream(validated); - }, -}; - -// Colors for terminal output (works on modern terminals including Windows) -const colors = { - green: '\x1b[0;32m', - blue: '\x1b[0;34m', - yellow: '\x1b[1;33m', - red: '\x1b[0;31m', - reset: '\x1b[0m', -}; - -const isWindows = process.platform === 'win32'; +// Create restricted fs for this script's directory +const fs = createRestrictedFs(__dirname, 'start.mjs'); // Track background processes for cleanup -let serverProcess = null; -let webProcess = null; -let electronProcess = null; - -/** - * Print colored output - */ -function log(message, color = 'reset') { - console.log(`${colors[color]}${message}${colors.reset}`); -} - -/** - * Print the header banner - */ -function printHeader() { - console.log('╔═══════════════════════════════════════════════════════╗'); - console.log('ā•‘ Automaker Production Mode ā•‘'); - console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); - console.log(''); -} - -/** - * Execute a command synchronously and return stdout - */ -function execCommand(command, options = {}) { - try { - return execSync(command, { - encoding: 'utf8', - stdio: 'pipe', - ...options, - }).trim(); - } catch { - return null; - } -} - -/** - * Get process IDs using a specific port (cross-platform) - */ -function getProcessesOnPort(port) { - const pids = new Set(); - - if (isWindows) { - // Windows: Use netstat to find PIDs - try { - const output = execCommand(`netstat -ano | findstr :${port}`); - if (output) { - const lines = output.split('\n'); - for (const line of lines) { - // Match lines with LISTENING or ESTABLISHED on our port - const match = line.match(/:\d+\s+.*?(\d+)\s*$/); - if (match) { - const pid = parseInt(match[1], 10); - if (pid > 0) pids.add(pid); - } - } - } - } catch { - // Ignore errors - } - } else { - // Unix: Use lsof - try { - const output = execCommand(`lsof -ti:${port}`); - if (output) { - output.split('\n').forEach((pid) => { - const parsed = parseInt(pid.trim(), 10); - if (parsed > 0) pids.add(parsed); - }); - } - } catch { - // Ignore errors - } - } - - return Array.from(pids); -} - -/** - * Kill a process by PID (cross-platform) - */ -function killProcess(pid) { - try { - if (isWindows) { - execCommand(`taskkill /F /PID ${pid}`); - } else { - process.kill(pid, 'SIGKILL'); - } - return true; - } catch { - return false; - } -} - -/** - * Check if a port is in use (without killing) - */ -function isPortInUse(port) { - const pids = getProcessesOnPort(port); - return pids.length > 0; -} - -/** - * Kill processes on a port and wait for it to be freed - */ -async function killPort(port) { - const pids = getProcessesOnPort(port); - - if (pids.length === 0) { - log(`āœ“ Port ${port} is available`, 'green'); - return true; - } - - log(`Killing process(es) on port ${port}: ${pids.join(', ')}`, 'yellow'); - - for (const pid of pids) { - killProcess(pid); - } - - // Wait for port to be freed (max 5 seconds) - for (let i = 0; i < 10; i++) { - await sleep(500); - const remainingPids = getProcessesOnPort(port); - if (remainingPids.length === 0) { - log(`āœ“ Port ${port} is now free`, 'green'); - return true; - } - } - - log(`Warning: Port ${port} may still be in use`, 'red'); - return false; -} - -/** - * Sleep for a given number of milliseconds - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Check if the server health endpoint is responding - */ -function checkHealth(port = 3008) { - return new Promise((resolve) => { - const req = http.get(`http://localhost:${port}/api/health`, (res) => { - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); - }); - }); -} - -/** - * Prompt the user for input - */ -function prompt(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -/** - * Run npm command using cross-spawn for Windows compatibility - */ -function runNpm(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npm', args, spawnOptions); -} - -/** - * Run an npm command and wait for completion - */ -function runNpmAndWait(args, options = {}) { - const child = runNpm(args, options); - return new Promise((resolve, reject) => { - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -/** - * Run npx command using cross-spawn for Windows compatibility - */ -function runNpx(args, options = {}) { - const { env, ...restOptions } = options; - const spawnOptions = { - stdio: 'inherit', - cwd: __dirname, - ...restOptions, - // Ensure environment variables are properly merged with process.env - env: { - ...process.env, - ...(env || {}), - }, - }; - // cross-spawn handles Windows .cmd files automatically - return crossSpawn('npx', args, spawnOptions); -} - -/** - * Kill a process tree using tree-kill - */ -function killProcessTree(pid) { - return new Promise((resolve) => { - if (!pid) { - resolve(); - return; - } - treeKill(pid, 'SIGTERM', (err) => { - if (err) { - // Try force kill if graceful termination fails - treeKill(pid, 'SIGKILL', () => resolve()); - } else { - resolve(); - } - }); - }); -} - -/** - * Cleanup function to kill all spawned processes - */ -async function cleanup() { - console.log('\nCleaning up...'); - - const killPromises = []; - - if (serverProcess && !serverProcess.killed && serverProcess.pid) { - killPromises.push(killProcessTree(serverProcess.pid)); - } - - if (webProcess && !webProcess.killed && webProcess.pid) { - killPromises.push(killProcessTree(webProcess.pid)); - } - - if (electronProcess && !electronProcess.killed && electronProcess.pid) { - killPromises.push(killProcessTree(electronProcess.pid)); - } - - await Promise.all(killPromises); -} +const processes = { + server: null, + web: null, + electron: null, +}; /** * Check if production builds exist + * @returns {{server: boolean, ui: boolean, electron: boolean}} */ function checkBuilds() { const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); @@ -361,31 +66,13 @@ function checkBuilds() { } /** - * Main function + * Build all production artifacts if needed */ -async function main() { - // Change to script directory - process.chdir(__dirname); - - printHeader(); - - // Check if node_modules exists - if (!fs.existsSync(path.join(__dirname, 'node_modules'))) { - log('Installing dependencies...', 'blue'); - const install = runNpm(['install'], { stdio: 'inherit' }); - await new Promise((resolve, reject) => { - install.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`npm install failed with code ${code}`)); - }); - }); - } - +async function ensureProductionBuilds() { // Always build shared packages first to ensure they're up to date - // (source may have changed even if dist directories exist) log('Building shared packages...', 'blue'); try { - await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }); + await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname); log('āœ“ Shared packages built', 'green'); } catch (error) { log(`Failed to build shared packages: ${error.message}`, 'red'); @@ -395,7 +82,11 @@ async function main() { // Always rebuild server to ensure it's in sync with packages log('Building server...', 'blue'); try { - await runNpmAndWait(['run', 'build'], { stdio: 'inherit', cwd: path.join(__dirname, 'apps', 'server') }); + await runNpmAndWait( + ['run', 'build'], + { stdio: 'inherit' }, + path.join(__dirname, 'apps', 'server') + ); log('āœ“ Server built', 'green'); } catch (error) { log(`Failed to build server: ${error.message}`, 'red'); @@ -410,10 +101,8 @@ async function main() { console.log(''); try { - // Build UI (includes Electron main process) log('Building UI...', 'blue'); - await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }); - + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); log('āœ“ Build complete!', 'green'); console.log(''); } catch (error) { @@ -424,155 +113,32 @@ async function main() { log('āœ“ UI builds found', 'green'); console.log(''); } +} - // Check for processes on required ports and prompt user - log('Checking for processes on ports 3007 and 3008...', 'yellow'); +/** + * Main function + */ +async function main() { + // Change to script directory + process.chdir(__dirname); - const webPortInUse = isPortInUse(3007); - const serverPortInUse = isPortInUse(3008); + printHeader('Automaker Production Mode'); - let webPort = 3007; - let serverPort = 3008; - let corsOriginEnv = process.env.CORS_ORIGIN || ''; + // Ensure dependencies are installed + await ensureDependencies(fs, __dirname); - if (webPortInUse || serverPortInUse) { - console.log(''); - if (webPortInUse) { - const pids = getProcessesOnPort(3007); - log(`⚠ Port 3007 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - if (serverPortInUse) { - const pids = getProcessesOnPort(3008); - log(`⚠ Port 3008 is in use by process(es): ${pids.join(', ')}`, 'yellow'); - } - console.log(''); + // Build production artifacts if needed + await ensureProductionBuilds(); - while (true) { - const choice = await prompt( - 'What would you like to do? (k)ill processes, (u)se different ports, or (c)ancel: ' - ); - const lowerChoice = choice.toLowerCase(); + // Resolve port configuration (check/kill/change ports) + const { webPort, serverPort, corsOriginEnv } = await resolvePortConfiguration(); - if (lowerChoice === 'k' || lowerChoice === 'kill') { - if (webPortInUse) { - await killPort(3007); - } else { - log(`āœ“ Port 3007 is available`, 'green'); - } - if (serverPortInUse) { - await killPort(3008); - } else { - log(`āœ“ Port 3008 is available`, 'green'); - } - break; - } else if (lowerChoice === 'u' || lowerChoice === 'use') { - // Prompt for new ports - while (true) { - const newWebPort = await prompt('Enter web port (default 3007): '); - const parsedWebPort = newWebPort.trim() ? parseInt(newWebPort.trim(), 10) : 3007; - - if (isNaN(parsedWebPort) || parsedWebPort < 1024 || parsedWebPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (isPortInUse(parsedWebPort)) { - const pids = getProcessesOnPort(parsedWebPort); - log( - `Port ${parsedWebPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - webPort = parsedWebPort; - break; - } - - while (true) { - const newServerPort = await prompt('Enter server port (default 3008): '); - const parsedServerPort = newServerPort.trim() ? parseInt(newServerPort.trim(), 10) : 3008; - - if (isNaN(parsedServerPort) || parsedServerPort < 1024 || parsedServerPort > 65535) { - log('Invalid port. Please enter a number between 1024 and 65535.', 'red'); - continue; - } - - if (parsedServerPort === webPort) { - log('Server port cannot be the same as web port.', 'red'); - continue; - } - - if (isPortInUse(parsedServerPort)) { - const pids = getProcessesOnPort(parsedServerPort); - log( - `Port ${parsedServerPort} is already in use by process(es): ${pids.join(', ')}`, - 'red' - ); - const useAnyway = await prompt('Use this port anyway? (y/n): '); - if (useAnyway.toLowerCase() !== 'y' && useAnyway.toLowerCase() !== 'yes') { - continue; - } - } - - serverPort = parsedServerPort; - break; - } - - log(`Using ports: Web=${webPort}, Server=${serverPort}`, 'blue'); - break; - } else if (lowerChoice === 'c' || lowerChoice === 'cancel') { - log('Cancelled.', 'yellow'); - process.exit(0); - } else { - log( - 'Invalid choice. Please enter k (kill), u (use different ports), or c (cancel).', - 'red' - ); - } - } - } else { - log(`āœ“ Port 3007 is available`, 'green'); - log(`āœ“ Port 3008 is available`, 'green'); - } - - // Ensure backend CORS allows whichever UI port we ended up using. - { - const existing = (process.env.CORS_ORIGIN || '') - .split(',') - .map((o) => o.trim()) - .filter(Boolean) - .filter((o) => o !== '*'); - const origins = new Set(existing); - origins.add(`http://localhost:${webPort}`); - origins.add(`http://127.0.0.1:${webPort}`); - corsOriginEnv = Array.from(origins).join(','); - } - console.log(''); - - // Show menu - console.log('═══════════════════════════════════════════════════════'); - console.log(' Select Application Mode:'); - console.log('═══════════════════════════════════════════════════════'); - console.log(' 1) Web Application (Browser)'); - console.log(' 2) Desktop Application (Electron)'); - console.log('═══════════════════════════════════════════════════════'); - console.log(''); + // Show mode selection menu + printModeMenu(); // Setup cleanup handlers - let cleaningUp = false; - const handleExit = async (signal) => { - if (cleaningUp) return; - cleaningUp = true; - await cleanup(); - process.exit(0); - }; - - process.on('SIGINT', () => handleExit('SIGINT')); - process.on('SIGTERM', () => handleExit('SIGTERM')); + const cleanup = createCleanupHandler(processes); + setupSignalHandlers(cleanup); // Prompt for choice while (true) { @@ -582,76 +148,44 @@ async function main() { console.log(''); log('Launching Web Application (Production Mode)...', 'blue'); - // Start the backend server in production mode - log(`Starting backend server on port ${serverPort}...`, 'blue'); - - // Create logs directory - if (!fs.existsSync(path.join(__dirname, 'logs'))) { - fs.mkdirSync(path.join(__dirname, 'logs'), { recursive: true }); - } - - // Start server in background, showing output in console AND logging to file - const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log')); - serverProcess = runNpm(['run', 'start'], { - stdio: ['ignore', 'pipe', 'pipe'], + // Start the backend server in PRODUCTION mode + // Uses "npm run start" in apps/server which runs the compiled dist/ + // NOT the Vite dev server (no HMR, faster startup) + processes.server = await startServerAndWait({ + serverPort, + corsOriginEnv, + npmArgs: ['run', 'start'], cwd: path.join(__dirname, 'apps', 'server'), - env: { - PORT: String(serverPort), - CORS_ORIGIN: corsOriginEnv, - }, + fs, + baseDir: __dirname, }); - // Pipe to both log file and console - serverProcess.stdout?.on('data', (data) => { - process.stdout.write(data); - logStream.write(data); - }); - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(data); - logStream.write(data); - }); - - log('Waiting for server to be ready...', 'yellow'); - - // Wait for server health check - const maxRetries = 30; - let serverReady = false; - - for (let i = 0; i < maxRetries; i++) { - if (await checkHealth(serverPort)) { - serverReady = true; - break; - } - process.stdout.write('.'); - await sleep(1000); - } - - console.log(''); - - if (!serverReady) { - log('Error: Server failed to start', 'red'); - console.log('Check logs/server.log for details'); + if (!processes.server) { cleanup(); process.exit(1); } - log('āœ“ Server is ready!', 'green'); log(`Starting web server...`, 'blue'); - // Start vite preview to serve built static files - webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - VITE_SERVER_URL: `http://localhost:${serverPort}`, + // Start vite preview to serve pre-built static files + // This is NOT Vite dev server - it just serves the dist/ folder + // No HMR, no compilation, just static file serving + processes.web = runNpx( + ['vite', 'preview', '--port', String(webPort)], + { + stdio: 'inherit', + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); log(`The application is available at: http://localhost:${webPort}`, 'green'); console.log(''); await new Promise((resolve) => { - webProcess.on('close', resolve); + processes.web.on('close', resolve); }); break; @@ -663,7 +197,7 @@ async function main() { // Run electron directly with the built main.js const electronMainPath = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); - + if (!fs.existsSync(electronMainPath)) { log('Error: Electron main process not built. Run build first.', 'red'); process.exit(1); @@ -672,36 +206,43 @@ async function main() { // Start vite preview to serve built static files for electron // (Electron in non-packaged mode needs a server to load from) log('Starting static file server...', 'blue'); - webProcess = runNpx(['vite', 'preview', '--port', String(webPort)], { - stdio: ['ignore', 'pipe', 'pipe'], - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - VITE_SERVER_URL: `http://localhost:${serverPort}`, + processes.web = runNpx( + ['vite', 'preview', '--port', String(webPort)], + { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + VITE_SERVER_URL: `http://localhost:${serverPort}`, + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); - // Wait a moment for vite preview to start + // Wait for vite preview to start await sleep(2000); - // Use electron from node_modules - electronProcess = runNpx(['electron', electronMainPath], { - stdio: 'inherit', - cwd: path.join(__dirname, 'apps', 'ui'), - env: { - TEST_PORT: String(webPort), - PORT: String(serverPort), - VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, - VITE_SERVER_URL: `http://localhost:${serverPort}`, - CORS_ORIGIN: corsOriginEnv, - NODE_ENV: 'production', + // Use electron from node_modules with NODE_ENV=production + // This ensures electron loads from the preview server, not Vite dev + processes.electron = runNpx( + ['electron', electronMainPath], + { + stdio: 'inherit', + env: { + TEST_PORT: String(webPort), + PORT: String(serverPort), + VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, + VITE_SERVER_URL: `http://localhost:${serverPort}`, + CORS_ORIGIN: corsOriginEnv, + NODE_ENV: 'production', + }, }, - }); + path.join(__dirname, 'apps', 'ui') + ); await new Promise((resolve) => { - electronProcess.on('close', () => { + processes.electron.on('close', () => { // Also kill vite preview when electron closes - if (webProcess && !webProcess.killed && webProcess.pid) { - killProcessTree(webProcess.pid); + if (processes.web && !processes.web.killed && processes.web.pid) { + killProcessTree(processes.web.pid); } resolve(); }); @@ -717,6 +258,7 @@ async function main() { // Run main function main().catch((err) => { console.error(err); + const cleanup = createCleanupHandler(processes); cleanup(); process.exit(1); }); From 6d41c7d0bc59f8b33bc4bd607b23a032cc3a38df Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:13:53 -0500 Subject: [PATCH 09/17] docs: update README for authentication setup and production launch - Revised instructions for starting Automaker, changing from `npm run dev` to `npm run start` for production mode. - Added a setup wizard for authentication on first run, with options for using Claude Code CLI or entering an API key. - Clarified development mode instructions, emphasizing the use of `npm run dev` for live reload and hot module replacement. --- README.md | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c8e1b84e..9ca0f368 100644 --- a/README.md +++ b/README.md @@ -120,29 +120,37 @@ npm install # 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly) npm run build:packages -# 4. Set up authentication (skip if using Claude Code CLI) -# If using Claude Code CLI: credentials are detected automatically -# If using API key directly, choose one method: - -# Option A: Environment variable -export ANTHROPIC_API_KEY="sk-ant-..." - -# Option B: Create .env file in project root -echo "ANTHROPIC_API_KEY=sk-ant-..." > .env - -# 5. Start Automaker (interactive launcher) -npm run dev +# 4. Start Automaker (production mode) +npm run start # Choose between: # 1. Web Application (browser at localhost:3007) # 2. Desktop Application (Electron - recommended) ``` -**Note:** The `npm run dev` command will: +**Note:** The `npm run start` command will: - Check for dependencies and install if needed -- Install Playwright browsers for E2E tests +- Build the application if needed - Kill any processes on ports 3007/3008 - Present an interactive menu to choose your run mode +- Run in production mode (no hot reload) + +**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to: + +- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically +- Enter an **API key** directly in the wizard + +If you prefer to set up authentication before running (e.g., for headless deployments or CI/CD), you can set it manually: + +```bash +# Option A: Environment variable +export ANTHROPIC_API_KEY="sk-ant-..." + +# Option B: Create .env file in project root +echo "ANTHROPIC_API_KEY=sk-ant-..." > .env +``` + +**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes. ## How to Run From d677910f40ccd0654cf7570271abd665f795d6f2 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:23:43 -0500 Subject: [PATCH 10/17] refactor: update permission handling and optimize performance measurement - Changed permissionMode settings in enhance and generate title routes to improve edit acceptance and default behavior. - Refactored performance measurement cleanup in the App component to only execute in development mode, preventing unnecessary operations in production. - Simplified the startServerAndWait function signature for better readability. --- .../src/routes/enhance-prompt/routes/enhance.ts | 4 +--- .../src/routes/features/routes/generate-title.ts | 4 +--- apps/ui/src/app.tsx | 14 ++++++++------ scripts/launcher-utils.mjs | 9 +-------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 744a67b0..ad6e9602 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -164,9 +164,7 @@ export function createEnhanceHandler( systemPrompt, maxTurns: 1, allowedTools: [], - // AUTONOMOUS MODE: Always bypass permissions - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, + permissionMode: 'acceptEdits', }, }); diff --git a/apps/server/src/routes/features/routes/generate-title.ts b/apps/server/src/routes/features/routes/generate-title.ts index 49c59801..2602de03 100644 --- a/apps/server/src/routes/features/routes/generate-title.ts +++ b/apps/server/src/routes/features/routes/generate-title.ts @@ -96,9 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P systemPrompt: SYSTEM_PROMPT, maxTurns: 1, allowedTools: [], - // AUTONOMOUS MODE: Always bypass permissions - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, + permissionMode: 'default', }, }); diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index c14ab6d0..a45073c6 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -18,12 +18,14 @@ export default function App() { // Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode // React's internal scheduler creates performance marks/measures that accumulate without cleanup useEffect(() => { - const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); - }; - const interval = setInterval(clearPerfEntries, 5000); - return () => clearInterval(interval); + if (import.meta.env.DEV) { + const clearPerfEntries = () => { + performance.clearMarks(); + performance.clearMeasures(); + }; + const interval = setInterval(clearPerfEntries, 5000); + return () => clearInterval(interval); + } }, []); // Run settings migration on startup (localStorage -> file storage) diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index af68e452..4e09b54a 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -558,14 +558,7 @@ export function setupSignalHandlers(cleanup) { * @param {object} options - Configuration options * @returns {Promise} - Server process */ -export async function startServerAndWait({ - serverPort, - corsOriginEnv, - npmArgs, - cwd, - fs, - baseDir, -}) { +export async function startServerAndWait({ serverPort, corsOriginEnv, npmArgs, cwd, fs, baseDir }) { log(`Starting backend server on port ${serverPort}...`, 'blue'); // Create logs directory From afb0937cb3031293c18a604a8a34e47d01e7aabc Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:26:26 -0500 Subject: [PATCH 11/17] refactor: update permissionMode to bypassPermissions in SDK options and tests - Changed permissionMode from 'default' to 'bypassPermissions' in sdk-options and claude-provider unit tests. - Added allowDangerouslySkipPermissions flag in claude-provider test to enhance permission handling. --- apps/server/tests/unit/lib/sdk-options.test.ts | 2 +- apps/server/tests/unit/providers/claude-provider.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 3faea516..d55210b0 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -234,7 +234,7 @@ describe('sdk-options.ts', () => { expect(options.cwd).toBe('/test/path'); expect(options.maxTurns).toBe(MAX_TURNS.maximum); expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]); - expect(options.permissionMode).toBe('default'); + expect(options.permissionMode).toBe('bypassPermissions'); }); it('should include system prompt when provided', async () => { diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 3dbd9982..96110295 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -73,7 +73,8 @@ describe('claude-provider.ts', () => { maxTurns: 10, cwd: '/test/dir', allowedTools: ['Read', 'Write'], - permissionMode: 'default', + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, }), }); }); From 586aabe11f62424c0f7bcc67faa5dbf7b8609742 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:36:22 -0500 Subject: [PATCH 12/17] chore: update .gitignore and improve cleanup handling in scripts - Added .claude/hans/ to .gitignore to prevent tracking of specific directory. - Updated cleanup calls in dev.mjs and start.mjs to use await for proper asynchronous handling. - Enhanced error handling during cleanup in case of failures. - Improved server failure handling in startServerAndWait function to ensure proper termination of failed processes. --- .gitignore | 1 + dev.mjs | 10 +++++++--- package-lock.json | 4 ++-- scripts/launcher-utils.mjs | 19 +++++++++++++++++++ start.mjs | 10 +++++++--- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 48470efe..7d02e8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ blob-report/ docker-compose.override.yml .claude/docker-compose.override.yml +.claude/hans/ pnpm-lock.yaml yarn.lock \ No newline at end of file diff --git a/dev.mjs b/dev.mjs index f2ad01cc..48ef9e67 100644 --- a/dev.mjs +++ b/dev.mjs @@ -117,7 +117,7 @@ async function main() { }); if (!processes.server) { - cleanup(); + await cleanup(); process.exit(1); } @@ -175,9 +175,13 @@ async function main() { } // Run main function -main().catch((err) => { +main().catch(async (err) => { console.error(err); const cleanup = createCleanupHandler(processes); - cleanup(); + try { + await cleanup(); + } catch (cleanupErr) { + console.error('Cleanup error:', cleanupErr); + } process.exit(1); }); diff --git a/package-lock.json b/package-lock.json index 48840b71..98ca8545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.7.1", + "version": "0.7.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -78,7 +78,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.7.1", + "version": "0.7.3", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index 4e09b54a..2a1d4e71 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -610,6 +610,25 @@ export async function startServerAndWait({ serverPort, corsOriginEnv, npmArgs, c if (!serverReady) { log('Error: Server failed to start', 'red'); console.log('Check logs/server.log for details'); + + // Clean up the spawned server process that failed health check + if (serverProcess && !serverProcess.killed && serverProcess.pid) { + log('Terminating failed server process...', 'yellow'); + try { + await killProcessTree(serverProcess.pid); + } catch (killErr) { + // Fallback: try direct kill if tree-kill fails + try { + serverProcess.kill('SIGKILL'); + } catch { + // Process may have already exited + } + } + } + + // Close the log stream + logStream.end(); + return null; } diff --git a/start.mjs b/start.mjs index 0992ccfe..54213e4f 100755 --- a/start.mjs +++ b/start.mjs @@ -161,7 +161,7 @@ async function main() { }); if (!processes.server) { - cleanup(); + await cleanup(); process.exit(1); } @@ -256,9 +256,13 @@ async function main() { } // Run main function -main().catch((err) => { +main().catch(async (err) => { console.error(err); const cleanup = createCleanupHandler(processes); - cleanup(); + try { + await cleanup(); + } catch (cleanupErr) { + console.error('Cleanup error:', cleanupErr); + } process.exit(1); }); From 22aa24ae0432fc16a077d35f808b8b1a160b3086 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 3 Jan 2026 23:53:44 -0500 Subject: [PATCH 13/17] feat: add Docker container launch option and update process handling - Introduced a new option to launch the application in a Docker container (Isolated Mode) from the main menu. - Added checks for the ANTHROPIC_API_KEY environment variable to ensure proper API functionality. - Updated process management to include Docker, allowing for better cleanup and handling of spawned processes. - Enhanced user prompts and logging for improved clarity during the launch process. --- .dockerignore | 1 + dev.mjs | 39 ++++++++++++++++- scripts/launcher-utils.mjs | 7 ++- start.mjs | 87 ++++++++++++++++++++++---------------- 4 files changed, 94 insertions(+), 40 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..40b878db --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/dev.mjs b/dev.mjs index 48ef9e67..7236d14f 100644 --- a/dev.mjs +++ b/dev.mjs @@ -42,6 +42,7 @@ const processes = { server: null, web: null, electron: null, + docker: null, }; /** @@ -96,7 +97,7 @@ async function main() { // Prompt for choice while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); + const choice = await prompt('Enter your choice (1, 2, or 3): '); if (choice === '1') { console.log(''); @@ -167,9 +168,43 @@ async function main() { processes.electron.on('close', resolve); }); + break; + } else if (choice === '3') { + console.log(''); + log('Launching Docker Container (Isolated Mode)...', 'blue'); + log('Building and starting Docker containers...', 'yellow'); + console.log(''); + + // Check if ANTHROPIC_API_KEY is set + if (!process.env.ANTHROPIC_API_KEY) { + log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); + log('The server will require an API key to function.', 'yellow'); + log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); + console.log(''); + } + + // Build and start containers with docker-compose + processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], { + stdio: 'inherit', + cwd: __dirname, + env: { + ...process.env, + }, + }); + + log('Docker containers starting...', 'blue'); + log('UI will be available at: http://localhost:3007', 'green'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + log('Press Ctrl+C to stop the containers.', 'yellow'); + + await new Promise((resolve) => { + processes.docker.on('close', resolve); + }); + break; } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); + log('Invalid choice. Please enter 1, 2, or 3.', 'red'); } } } diff --git a/scripts/launcher-utils.mjs b/scripts/launcher-utils.mjs index 2a1d4e71..215c0dc2 100644 --- a/scripts/launcher-utils.mjs +++ b/scripts/launcher-utils.mjs @@ -496,6 +496,7 @@ export function printModeMenu() { console.log('═══════════════════════════════════════════════════════'); console.log(' 1) Web Application (Browser)'); console.log(' 2) Desktop Application (Electron)'); + console.log(' 3) Docker Container (Isolated)'); console.log('═══════════════════════════════════════════════════════'); console.log(''); } @@ -506,7 +507,7 @@ export function printModeMenu() { /** * Create a cleanup handler for spawned processes - * @param {object} processes - Object with process references {server, web, electron} + * @param {object} processes - Object with process references {server, web, electron, docker} * @returns {Function} - Cleanup function */ export function createCleanupHandler(processes) { @@ -527,6 +528,10 @@ export function createCleanupHandler(processes) { killPromises.push(killProcessTree(processes.electron.pid)); } + if (processes.docker && !processes.docker.killed && processes.docker.pid) { + killPromises.push(killProcessTree(processes.docker.pid)); + } + await Promise.all(killPromises); }; } diff --git a/start.mjs b/start.mjs index 54213e4f..22e12428 100755 --- a/start.mjs +++ b/start.mjs @@ -18,6 +18,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; import { createRestrictedFs, log, @@ -36,6 +37,9 @@ import { sleep, } from './scripts/launcher-utils.mjs'; +const require = createRequire(import.meta.url); +const crossSpawn = require('cross-spawn'); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,26 +51,11 @@ const processes = { server: null, web: null, electron: null, + docker: null, }; /** - * Check if production builds exist - * @returns {{server: boolean, ui: boolean, electron: boolean}} - */ -function checkBuilds() { - const serverDist = path.join(__dirname, 'apps', 'server', 'dist'); - const uiDist = path.join(__dirname, 'apps', 'ui', 'dist'); - const electronDist = path.join(__dirname, 'apps', 'ui', 'dist-electron', 'main.js'); - - return { - server: fs.existsSync(serverDist), - ui: fs.existsSync(uiDist), - electron: fs.existsSync(electronDist), - }; -} - -/** - * Build all production artifacts if needed + * Build all production artifacts */ async function ensureProductionBuilds() { // Always build shared packages first to ensure they're up to date @@ -93,25 +82,15 @@ async function ensureProductionBuilds() { process.exit(1); } - // Check if UI/Electron builds exist (these are slower, so only build if missing) - const builds = checkBuilds(); - - if (!builds.ui || !builds.electron) { - log('UI/Electron builds not found. Building...', 'yellow'); - console.log(''); - - try { - log('Building UI...', 'blue'); - await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); - log('āœ“ Build complete!', 'green'); - console.log(''); - } catch (error) { - log(`Build failed: ${error.message}`, 'red'); - process.exit(1); - } - } else { - log('āœ“ UI builds found', 'green'); + // Always rebuild UI to ensure it's in sync with latest code + log('Building UI...', 'blue'); + try { + await runNpmAndWait(['run', 'build'], { stdio: 'inherit' }, __dirname); + log('āœ“ UI built', 'green'); console.log(''); + } catch (error) { + log(`Failed to build UI: ${error.message}`, 'red'); + process.exit(1); } } @@ -142,7 +121,7 @@ async function main() { // Prompt for choice while (true) { - const choice = await prompt('Enter your choice (1 or 2): '); + const choice = await prompt('Enter your choice (1, 2, or 3): '); if (choice === '1') { console.log(''); @@ -248,9 +227,43 @@ async function main() { }); }); + break; + } else if (choice === '3') { + console.log(''); + log('Launching Docker Container (Isolated Mode)...', 'blue'); + log('Building and starting Docker containers...', 'yellow'); + console.log(''); + + // Check if ANTHROPIC_API_KEY is set + if (!process.env.ANTHROPIC_API_KEY) { + log('Warning: ANTHROPIC_API_KEY environment variable is not set.', 'yellow'); + log('The server will require an API key to function.', 'yellow'); + log('Set it with: export ANTHROPIC_API_KEY=your-key', 'yellow'); + console.log(''); + } + + // Build and start containers with docker-compose + processes.docker = crossSpawn('docker', ['compose', 'up', '--build'], { + stdio: 'inherit', + cwd: __dirname, + env: { + ...process.env, + }, + }); + + log('Docker containers starting...', 'blue'); + log('UI will be available at: http://localhost:3007', 'green'); + log('API will be available at: http://localhost:3008', 'green'); + console.log(''); + log('Press Ctrl+C to stop the containers.', 'yellow'); + + await new Promise((resolve) => { + processes.docker.on('close', resolve); + }); + break; } else { - log('Invalid choice. Please enter 1 or 2.', 'red'); + log('Invalid choice. Please enter 1, 2, or 3.', 'red'); } } } From abddfad063cf0d5101755d9119129ebe820bbbe2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 05:16:06 +0000 Subject: [PATCH 14/17] test: add comprehensive unit tests for IdeationService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 28 unit tests covering all major IdeationService functionality - Test session management (start, get, stop, running state) - Test idea CRUD operations (create, read, update, delete, archive) - Test idea to feature conversion with user stories and notes - Test project analysis and caching - Test prompt management and filtering - Test AI-powered suggestion generation - Mock all external dependencies (fs, platform, utils, providers) šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Web Dev Cody --- .../unit/services/ideation-service.test.ts | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 apps/server/tests/unit/services/ideation-service.test.ts diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts new file mode 100644 index 00000000..6cc9e340 --- /dev/null +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -0,0 +1,763 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { IdeationService } from '@/services/ideation-service.js'; +import type { EventEmitter } from '@/lib/events.js'; +import type { SettingsService } from '@/services/settings-service.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import * as platform from '@automaker/platform'; +import * as utils from '@automaker/utils'; +import type { + CreateIdeaInput, + UpdateIdeaInput, + Idea, + IdeationSession, + StartSessionOptions, +} from '@automaker/types'; +import { ProviderFactory } from '@/providers/provider-factory.js'; + +// Mock dependencies +vi.mock('@/lib/secure-fs.js'); +vi.mock('@automaker/platform'); +vi.mock('@automaker/utils'); +vi.mock('@/providers/provider-factory.js'); +vi.mock('@/lib/sdk-options.js', () => ({ + createChatOptions: vi.fn(() => ({ + model: 'claude-sonnet-4-20250514', + systemPrompt: 'test prompt', + })), + validateWorkingDirectory: vi.fn(), +})); + +describe('IdeationService', () => { + let service: IdeationService; + let mockEvents: EventEmitter; + let mockSettingsService: SettingsService; + let mockFeatureLoader: FeatureLoader; + const testProjectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock event emitter + mockEvents = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + } as unknown as EventEmitter; + + // Create mock settings service + mockSettingsService = {} as SettingsService; + + // Create mock feature loader + mockFeatureLoader = { + getAll: vi.fn().mockResolvedValue([]), + } as unknown as FeatureLoader; + + // Mock platform functions + vi.mocked(platform.ensureIdeationDir).mockResolvedValue(undefined); + vi.mocked(platform.getIdeaDir).mockReturnValue('/test/project/.automaker/ideation/ideas/idea-123'); + vi.mocked(platform.getIdeaPath).mockReturnValue( + '/test/project/.automaker/ideation/ideas/idea-123/idea.json' + ); + vi.mocked(platform.getIdeasDir).mockReturnValue('/test/project/.automaker/ideation/ideas'); + vi.mocked(platform.getIdeationSessionPath).mockReturnValue( + '/test/project/.automaker/ideation/sessions/session-123.json' + ); + vi.mocked(platform.getIdeationSessionsDir).mockReturnValue( + '/test/project/.automaker/ideation/sessions' + ); + vi.mocked(platform.getIdeationAnalysisPath).mockReturnValue( + '/test/project/.automaker/ideation/analysis.json' + ); + + // Mock utils + vi.mocked(utils.loadContextFiles).mockResolvedValue({ + formattedPrompt: 'Test context', + files: [], + }); + vi.mocked(utils.isAbortError).mockReturnValue(false); + + service = new IdeationService(mockEvents, mockSettingsService, mockFeatureLoader); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ============================================================================ + // Session Management Tests + // ============================================================================ + + describe('Session Management', () => { + describe('startSession', () => { + it('should create a new session with default options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + + expect(session).toBeDefined(); + expect(session.id).toMatch(/^session-/); + expect(session.projectPath).toBe(testProjectPath); + expect(session.status).toBe('active'); + expect(session.createdAt).toBeDefined(); + expect(session.updatedAt).toBeDefined(); + expect(platform.ensureIdeationDir).toHaveBeenCalledWith(testProjectPath); + expect(secureFs.writeFile).toHaveBeenCalled(); + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-started', { + sessionId: session.id, + projectPath: testProjectPath, + }); + }); + + it('should create session with custom options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const options: StartSessionOptions = { + promptCategory: 'features', + promptId: 'new-features', + }; + + const session = await service.startSession(testProjectPath, options); + + expect(session.promptCategory).toBe('features'); + expect(session.promptId).toBe('new-features'); + }); + + it('should send initial message if provided in options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({ features: [] })); + + // Mock provider + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: 'AI response', + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const options: StartSessionOptions = { + initialMessage: 'Hello, AI!', + }; + + await service.startSession(testProjectPath, options); + + // Give time for the async message to process + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockProvider.executeQuery).toHaveBeenCalled(); + }); + }); + + describe('getSession', () => { + it('should return null for non-existent session', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getSession(testProjectPath, 'non-existent'); + + expect(result).toBeNull(); + }); + + it('should return active session from memory', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + const retrieved = await service.getSession(testProjectPath, session.id); + + expect(retrieved).toBeDefined(); + expect(retrieved?.id).toBe(session.id); + expect(retrieved?.messages).toEqual([]); + }); + + it('should load session from disk if not in memory', async () => { + const mockSession: IdeationSession = { + id: 'session-123', + projectPath: testProjectPath, + status: 'active', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const sessionData = { + session: mockSession, + messages: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(sessionData)); + + const result = await service.getSession(testProjectPath, 'session-123'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('session-123'); + }); + }); + + describe('stopSession', () => { + it('should stop an active session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + await service.stopSession(session.id); + + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-ended', { + sessionId: session.id, + }); + }); + + it('should handle stopping non-existent session gracefully', async () => { + await expect(service.stopSession('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('isSessionRunning', () => { + it('should return false for non-existent session', () => { + expect(service.isSessionRunning('non-existent')).toBe(false); + }); + + it('should return false for idle session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + expect(service.isSessionRunning(session.id)).toBe(false); + }); + }); + }); + + // ============================================================================ + // Ideas CRUD Tests + // ============================================================================ + + describe('Ideas CRUD', () => { + describe('createIdea', () => { + it('should create a new idea with required fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Test Idea', + description: 'This is a test idea', + category: 'features', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea).toBeDefined(); + expect(idea.id).toMatch(/^idea-/); + expect(idea.title).toBe('Test Idea'); + expect(idea.description).toBe('This is a test idea'); + expect(idea.category).toBe('features'); + expect(idea.status).toBe('raw'); + expect(idea.impact).toBe('medium'); + expect(idea.effort).toBe('medium'); + expect(secureFs.mkdir).toHaveBeenCalled(); + expect(secureFs.writeFile).toHaveBeenCalled(); + }); + + it('should create idea with all optional fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Full Idea', + description: 'Complete idea', + category: 'features', + status: 'refined', + impact: 'high', + effort: 'low', + conversationId: 'conv-123', + sourcePromptId: 'prompt-123', + userStories: ['Story 1', 'Story 2'], + notes: 'Additional notes', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea.status).toBe('refined'); + expect(idea.impact).toBe('high'); + expect(idea.effort).toBe('low'); + expect(idea.conversationId).toBe('conv-123'); + expect(idea.sourcePromptId).toBe('prompt-123'); + expect(idea.userStories).toEqual(['Story 1', 'Story 2']); + expect(idea.notes).toBe('Additional notes'); + }); + }); + + describe('getIdeas', () => { + it('should return empty array when ideas directory does not exist', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toEqual([]); + }); + + it('should load all ideas from disk', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const idea1: Idea = { + id: 'idea-1', + title: 'Idea 1', + description: 'First idea', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const idea2: Idea = { + id: 'idea-2', + title: 'Idea 2', + description: 'Second idea', + category: 'bugs', + status: 'refined', + impact: 'high', + effort: 'low', + createdAt: '2024-01-02T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(idea1)) + .mockResolvedValueOnce(JSON.stringify(idea2)); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(2); + expect(ideas[0].id).toBe('idea-2'); // Sorted by updatedAt descending + expect(ideas[1].id).toBe('idea-1'); + }); + + it('should skip invalid idea files', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const validIdea: Idea = { + id: 'idea-1', + title: 'Valid Idea', + description: 'Valid', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(validIdea)) + .mockRejectedValueOnce(new Error('Invalid JSON')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(1); + expect(ideas[0].id).toBe('idea-1'); + }); + }); + + describe('getIdea', () => { + it('should return idea by id', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test Idea', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const idea = await service.getIdea(testProjectPath, 'idea-123'); + + expect(idea).toBeDefined(); + expect(idea?.id).toBe('idea-123'); + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const idea = await service.getIdea(testProjectPath, 'non-existent'); + + expect(idea).toBeNull(); + }); + }); + + describe('updateIdea', () => { + it('should update idea fields', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Original Title', + description: 'Original', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const updates: UpdateIdeaInput = { + title: 'Updated Title', + status: 'refined', + }; + + const updated = await service.updateIdea(testProjectPath, 'idea-123', updates); + + expect(updated).toBeDefined(); + expect(updated?.title).toBe('Updated Title'); + expect(updated?.status).toBe('refined'); + expect(updated?.description).toBe('Original'); // Unchanged + expect(updated?.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); // Should be updated + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const updated = await service.updateIdea(testProjectPath, 'non-existent', { + title: 'New Title', + }); + + expect(updated).toBeNull(); + }); + }); + + describe('deleteIdea', () => { + it('should delete idea directory', async () => { + vi.mocked(secureFs.rm).mockResolvedValue(undefined); + + await service.deleteIdea(testProjectPath, 'idea-123'); + + expect(secureFs.rm).toHaveBeenCalledWith( + expect.stringContaining('idea-123'), + expect.objectContaining({ recursive: true }) + ); + }); + + it('should handle non-existent idea gracefully', async () => { + vi.mocked(secureFs.rm).mockRejectedValue(new Error('ENOENT')); + + await expect(service.deleteIdea(testProjectPath, 'non-existent')).resolves.not.toThrow(); + }); + }); + + describe('archiveIdea', () => { + it('should set idea status to archived', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const archived = await service.archiveIdea(testProjectPath, 'idea-123'); + + expect(archived).toBeDefined(); + expect(archived?.status).toBe('archived'); + }); + }); + }); + + // ============================================================================ + // Conversion Tests + // ============================================================================ + + describe('Idea to Feature Conversion', () => { + describe('convertToFeature', () => { + it('should convert idea to feature with basic fields', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Add Dark Mode', + description: 'Implement dark mode theme', + category: 'features', + status: 'refined', + impact: 'high', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature).toBeDefined(); + expect(feature.id).toMatch(/^feature-/); + expect(feature.title).toBe('Add Dark Mode'); + expect(feature.description).toBe('Implement dark mode theme'); + expect(feature.category).toBe('ui'); // features -> ui mapping + expect(feature.status).toBe('backlog'); + }); + + it('should include user stories in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + userStories: ['As a user, I want X', 'As a user, I want Y'], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## User Stories'); + expect(feature.description).toContain('As a user, I want X'); + expect(feature.description).toContain('As a user, I want Y'); + }); + + it('should include notes in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + notes: 'Important implementation notes', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## Notes'); + expect(feature.description).toContain('Important implementation notes'); + }); + + it('should throw error for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + await expect(service.convertToFeature(testProjectPath, 'non-existent')).rejects.toThrow( + 'Idea non-existent not found' + ); + }); + }); + }); + + // ============================================================================ + // Project Analysis Tests + // ============================================================================ + + describe('Project Analysis', () => { + describe('analyzeProject', () => { + it('should analyze project and generate suggestions', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue( + JSON.stringify({ + name: 'test-project', + dependencies: {}, + }) + ); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([]); + + const result = await service.analyzeProject(testProjectPath); + + expect(result).toBeDefined(); + expect(result.projectPath).toBe(testProjectPath); + expect(result.analyzedAt).toBeDefined(); + expect(result.suggestions).toBeDefined(); + expect(Array.isArray(result.suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-started', + }) + ); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-complete', + }) + ); + }); + + it('should emit error event on failure', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('Read failed')); + + await expect(service.analyzeProject(testProjectPath)).rejects.toThrow(); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-error', + }) + ); + }); + }); + + describe('getCachedAnalysis', () => { + it('should return cached analysis if exists', async () => { + const mockAnalysis = { + projectPath: testProjectPath, + analyzedAt: '2024-01-01T00:00:00.000Z', + totalFiles: 10, + suggestions: [], + summary: 'Test summary', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockAnalysis)); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toEqual(mockAnalysis); + }); + + it('should return null if cache does not exist', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toBeNull(); + }); + }); + }); + + // ============================================================================ + // Prompt Management Tests + // ============================================================================ + + describe('Prompt Management', () => { + describe('getPromptCategories', () => { + it('should return list of prompt categories', () => { + const categories = service.getPromptCategories(); + + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + expect(categories[0]).toHaveProperty('id'); + expect(categories[0]).toHaveProperty('label'); + }); + }); + + describe('getAllPrompts', () => { + it('should return all guided prompts', () => { + const prompts = service.getAllPrompts(); + + expect(Array.isArray(prompts)).toBe(true); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts[0]).toHaveProperty('id'); + expect(prompts[0]).toHaveProperty('category'); + expect(prompts[0]).toHaveProperty('title'); + expect(prompts[0]).toHaveProperty('prompt'); + }); + }); + + describe('getPromptsByCategory', () => { + it('should return prompts filtered by category', () => { + const allPrompts = service.getAllPrompts(); + const firstCategory = allPrompts[0].category; + + const filtered = service.getPromptsByCategory(firstCategory); + + expect(Array.isArray(filtered)).toBe(true); + filtered.forEach((prompt) => { + expect(prompt.category).toBe(firstCategory); + }); + }); + + it('should return empty array for non-existent category', () => { + const filtered = service.getPromptsByCategory('non-existent-category' as any); + + expect(filtered).toEqual([]); + }); + }); + }); + + // ============================================================================ + // Suggestions Generation Tests + // ============================================================================ + + describe('Suggestion Generation', () => { + describe('generateSuggestions', () => { + it('should generate suggestions for a prompt', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([ + { + title: 'Add user authentication', + description: 'Implement auth', + category: 'security', + impact: 'high', + effort: 'high', + }, + ]), + }; + }, + }), + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + const firstPrompt = prompts[0]; + + const suggestions = await service.generateSuggestions( + testProjectPath, + firstPrompt.id, + 'features', + 5 + ); + + expect(Array.isArray(suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:suggestions', + expect.objectContaining({ + type: 'started', + }) + ); + }); + + it('should throw error for non-existent prompt', async () => { + await expect( + service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5) + ).rejects.toThrow('Prompt non-existent not found'); + }); + }); + }); +}); From 5c95d6d58e3f1572803290deb2a29c23a57ab6fb Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sun, 4 Jan 2026 00:22:06 -0500 Subject: [PATCH 15/17] fix: update category mapping and improve ID generation format in IdeationService - Changed the category mapping for 'feature' from 'feature' to 'ui'. - Updated ID generation format to use hyphens instead of underscores for better readability. - Enhanced unit tests to reflect the updated category and ensure proper functionality. --- apps/server/src/services/ideation-service.ts | 4 +- .../unit/services/ideation-service.test.ts | 37 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index d2fde6dd..3528c858 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -1631,7 +1631,7 @@ Focus on practical, implementable suggestions that would genuinely improve the p private mapIdeaCategoryToFeatureCategory(category: IdeaCategory): string { const mapping: Record = { - feature: 'feature', + feature: 'ui', 'ux-ui': 'enhancement', dx: 'chore', growth: 'feature', @@ -1674,6 +1674,6 @@ Focus on practical, implementable suggestions that would genuinely improve the p } private generateId(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } } diff --git a/apps/server/tests/unit/services/ideation-service.test.ts b/apps/server/tests/unit/services/ideation-service.test.ts index 6cc9e340..346fe442 100644 --- a/apps/server/tests/unit/services/ideation-service.test.ts +++ b/apps/server/tests/unit/services/ideation-service.test.ts @@ -15,10 +15,26 @@ import type { } from '@automaker/types'; import { ProviderFactory } from '@/providers/provider-factory.js'; +// Create a shared mock logger instance for assertions using vi.hoisted +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + // Mock dependencies vi.mock('@/lib/secure-fs.js'); vi.mock('@automaker/platform'); -vi.mock('@automaker/utils'); +vi.mock('@automaker/utils', async () => { + const actual = await vi.importActual('@automaker/utils'); + return { + ...actual, + createLogger: vi.fn(() => mockLogger), + loadContextFiles: vi.fn(), + isAbortError: vi.fn(), + }; +}); vi.mock('@/providers/provider-factory.js'); vi.mock('@/lib/sdk-options.js', () => ({ createChatOptions: vi.fn(() => ({ @@ -56,7 +72,9 @@ describe('IdeationService', () => { // Mock platform functions vi.mocked(platform.ensureIdeationDir).mockResolvedValue(undefined); - vi.mocked(platform.getIdeaDir).mockReturnValue('/test/project/.automaker/ideation/ideas/idea-123'); + vi.mocked(platform.getIdeaDir).mockReturnValue( + '/test/project/.automaker/ideation/ideas/idea-123' + ); vi.mocked(platform.getIdeaPath).mockReturnValue( '/test/project/.automaker/ideation/ideas/idea-123/idea.json' ); @@ -71,7 +89,7 @@ describe('IdeationService', () => { '/test/project/.automaker/ideation/analysis.json' ); - // Mock utils + // Mock utils (already mocked above, but reset return values) vi.mocked(utils.loadContextFiles).mockResolvedValue({ formattedPrompt: 'Test context', files: [], @@ -497,7 +515,7 @@ describe('IdeationService', () => { id: 'idea-123', title: 'Add Dark Mode', description: 'Implement dark mode theme', - category: 'features', + category: 'feature', status: 'refined', impact: 'high', effort: 'medium', @@ -613,7 +631,14 @@ describe('IdeationService', () => { }); it('should emit error event on failure', async () => { - vi.mocked(secureFs.readFile).mockRejectedValue(new Error('Read failed')); + // Mock writeFile to fail (this is called after gatherProjectStructure and isn't caught) + vi.mocked(secureFs.readFile).mockResolvedValue( + JSON.stringify({ + name: 'test-project', + dependencies: {}, + }) + ); + vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed')); await expect(service.analyzeProject(testProjectPath)).rejects.toThrow(); @@ -665,7 +690,7 @@ describe('IdeationService', () => { expect(Array.isArray(categories)).toBe(true); expect(categories.length).toBeGreaterThan(0); expect(categories[0]).toHaveProperty('id'); - expect(categories[0]).toHaveProperty('label'); + expect(categories[0]).toHaveProperty('name'); }); }); From ac92725a6cecbdcb5fe7a5bcb12ba229adb1870a Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sun, 4 Jan 2026 00:38:01 -0500 Subject: [PATCH 16/17] feat: enhance ideation routes with event handling and new suggestion feature - Updated the ideation routes to include an EventEmitter for better event management. - Added a new endpoint to handle adding suggestions to the board, ensuring consistent category mapping. - Modified existing routes to emit events for idea creation, update, and deletion, improving frontend notifications. - Refactored the convert and create idea handlers to utilize the new event system. - Removed static guided prompts data in favor of dynamic fetching from the backend API. --- apps/server/src/index.ts | 2 +- apps/server/src/routes/ideation/index.ts | 20 +- .../routes/ideation/routes/add-suggestion.ts | 70 ++++ .../src/routes/ideation/routes/convert.ts | 16 + .../routes/ideation/routes/ideas-create.ts | 10 +- .../routes/ideation/routes/ideas-delete.ts | 10 +- .../routes/ideation/routes/ideas-update.ts | 10 +- .../src/routes/ideation/routes/prompts.ts | 2 +- .../routes/ideation/routes/session-stop.ts | 17 +- .../ideation/routes/suggestions-generate.ts | 5 +- apps/server/src/services/ideation-service.ts | 61 ++- .../components/prompt-category-grid.tsx | 64 +-- .../ideation-view/components/prompt-list.tsx | 124 +++--- .../ideation-view/data/guided-prompts.ts | 391 ------------------ .../components/views/ideation-view/index.tsx | 37 +- apps/ui/src/hooks/index.ts | 1 + apps/ui/src/hooks/use-guided-prompts.ts | 86 ++++ apps/ui/src/lib/electron.ts | 12 + apps/ui/src/lib/http-api-client.ts | 36 +- libs/types/src/event.ts | 6 +- 20 files changed, 442 insertions(+), 538 deletions(-) create mode 100644 apps/server/src/routes/ideation/routes/add-suggestion.ts delete mode 100644 apps/ui/src/components/views/ideation-view/data/guided-prompts.ts create mode 100644 apps/ui/src/hooks/use-guided-prompts.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ab53a579..5ff95f39 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -218,7 +218,7 @@ app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); -app.use('/api/ideation', createIdeationRoutes(ideationService, featureLoader)); +app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/ideation/index.ts b/apps/server/src/routes/ideation/index.ts index cd64c739..95fe128b 100644 --- a/apps/server/src/routes/ideation/index.ts +++ b/apps/server/src/routes/ideation/index.ts @@ -3,6 +3,7 @@ */ import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; import { validatePathParams } from '../../middleware/validate-paths.js'; import type { IdeationService } from '../../services/ideation-service.js'; import type { FeatureLoader } from '../../services/feature-loader.js'; @@ -19,10 +20,12 @@ import { createIdeasUpdateHandler } from './routes/ideas-update.js'; import { createIdeasDeleteHandler } from './routes/ideas-delete.js'; import { createAnalyzeHandler, createGetAnalysisHandler } from './routes/analyze.js'; import { createConvertHandler } from './routes/convert.js'; +import { createAddSuggestionHandler } from './routes/add-suggestion.js'; import { createPromptsHandler, createPromptsByCategoryHandler } from './routes/prompts.js'; import { createSuggestionsGenerateHandler } from './routes/suggestions-generate.js'; export function createIdeationRoutes( + events: EventEmitter, ideationService: IdeationService, featureLoader: FeatureLoader ): Router { @@ -35,7 +38,7 @@ export function createIdeationRoutes( createSessionStartHandler(ideationService) ); router.post('/session/message', createSessionMessageHandler(ideationService)); - router.post('/session/stop', createSessionStopHandler(ideationService)); + router.post('/session/stop', createSessionStopHandler(events, ideationService)); router.post( '/session/get', validatePathParams('projectPath'), @@ -51,7 +54,7 @@ export function createIdeationRoutes( router.post( '/ideas/create', validatePathParams('projectPath'), - createIdeasCreateHandler(ideationService) + createIdeasCreateHandler(events, ideationService) ); router.post( '/ideas/get', @@ -61,12 +64,12 @@ export function createIdeationRoutes( router.post( '/ideas/update', validatePathParams('projectPath'), - createIdeasUpdateHandler(ideationService) + createIdeasUpdateHandler(events, ideationService) ); router.post( '/ideas/delete', validatePathParams('projectPath'), - createIdeasDeleteHandler(ideationService) + createIdeasDeleteHandler(events, ideationService) ); // Project analysis @@ -81,7 +84,14 @@ export function createIdeationRoutes( router.post( '/convert', validatePathParams('projectPath'), - createConvertHandler(ideationService, featureLoader) + createConvertHandler(events, ideationService, featureLoader) + ); + + // Add suggestion to board as a feature + router.post( + '/add-suggestion', + validatePathParams('projectPath'), + createAddSuggestionHandler(ideationService, featureLoader) ); // Guided prompts (no validation needed - static data) diff --git a/apps/server/src/routes/ideation/routes/add-suggestion.ts b/apps/server/src/routes/ideation/routes/add-suggestion.ts new file mode 100644 index 00000000..3326bfc3 --- /dev/null +++ b/apps/server/src/routes/ideation/routes/add-suggestion.ts @@ -0,0 +1,70 @@ +/** + * POST /add-suggestion - Add an analysis suggestion to the board as a feature + * + * This endpoint converts an AnalysisSuggestion to a Feature using the + * IdeationService's mapIdeaCategoryToFeatureCategory for consistent category mapping. + * This ensures a single source of truth for the conversion logic. + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AnalysisSuggestion } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAddSuggestionHandler( + ideationService: IdeationService, + featureLoader: FeatureLoader +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, suggestion } = req.body as { + projectPath: string; + suggestion: AnalysisSuggestion; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!suggestion) { + res.status(400).json({ success: false, error: 'suggestion is required' }); + return; + } + + if (!suggestion.title) { + res.status(400).json({ success: false, error: 'suggestion.title is required' }); + return; + } + + if (!suggestion.category) { + res.status(400).json({ success: false, error: 'suggestion.category is required' }); + return; + } + + // Build description with rationale if provided + const description = suggestion.rationale + ? `${suggestion.description}\n\n**Rationale:** ${suggestion.rationale}` + : suggestion.description; + + // Use the service's category mapping for consistency + const featureCategory = ideationService.mapSuggestionCategoryToFeatureCategory( + suggestion.category + ); + + // Create the feature + const feature = await featureLoader.create(projectPath, { + title: suggestion.title, + description, + category: featureCategory, + status: 'backlog', + }); + + res.json({ success: true, featureId: feature.id }); + } catch (error) { + logError(error, 'Add suggestion to board failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/ideation/routes/convert.ts b/apps/server/src/routes/ideation/routes/convert.ts index ab83164d..e1939bb4 100644 --- a/apps/server/src/routes/ideation/routes/convert.ts +++ b/apps/server/src/routes/ideation/routes/convert.ts @@ -3,12 +3,14 @@ */ import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; import type { IdeationService } from '../../../services/ideation-service.js'; import type { FeatureLoader } from '../../../services/feature-loader.js'; import type { ConvertToFeatureOptions } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; export function createConvertHandler( + events: EventEmitter, ideationService: IdeationService, featureLoader: FeatureLoader ) { @@ -49,8 +51,22 @@ export function createConvertHandler( // Delete the idea unless keepIdea is explicitly true if (!keepIdea) { await ideationService.deleteIdea(projectPath, ideaId); + + // Emit idea deleted event + events.emit('ideation:idea-deleted', { + projectPath, + ideaId, + }); } + // Emit idea converted event to notify frontend + events.emit('ideation:idea-converted', { + projectPath, + ideaId, + featureId: feature.id, + keepIdea: !!keepIdea, + }); + // Return featureId as expected by the frontend API interface res.json({ success: true, featureId: feature.id }); } catch (error) { diff --git a/apps/server/src/routes/ideation/routes/ideas-create.ts b/apps/server/src/routes/ideation/routes/ideas-create.ts index d854622e..bf368fd9 100644 --- a/apps/server/src/routes/ideation/routes/ideas-create.ts +++ b/apps/server/src/routes/ideation/routes/ideas-create.ts @@ -3,11 +3,12 @@ */ import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; import type { IdeationService } from '../../../services/ideation-service.js'; import type { CreateIdeaInput } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; -export function createIdeasCreateHandler(ideationService: IdeationService) { +export function createIdeasCreateHandler(events: EventEmitter, ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { const { projectPath, idea } = req.body as { @@ -34,6 +35,13 @@ export function createIdeasCreateHandler(ideationService: IdeationService) { } const created = await ideationService.createIdea(projectPath, idea); + + // Emit idea created event for frontend notification + events.emit('ideation:idea-created', { + projectPath, + idea: created, + }); + res.json({ success: true, idea: created }); } catch (error) { logError(error, 'Create idea failed'); diff --git a/apps/server/src/routes/ideation/routes/ideas-delete.ts b/apps/server/src/routes/ideation/routes/ideas-delete.ts index 931ae32a..b1bcf006 100644 --- a/apps/server/src/routes/ideation/routes/ideas-delete.ts +++ b/apps/server/src/routes/ideation/routes/ideas-delete.ts @@ -3,10 +3,11 @@ */ import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; import type { IdeationService } from '../../../services/ideation-service.js'; import { getErrorMessage, logError } from '../common.js'; -export function createIdeasDeleteHandler(ideationService: IdeationService) { +export function createIdeasDeleteHandler(events: EventEmitter, ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { const { projectPath, ideaId } = req.body as { @@ -25,6 +26,13 @@ export function createIdeasDeleteHandler(ideationService: IdeationService) { } await ideationService.deleteIdea(projectPath, ideaId); + + // Emit idea deleted event for frontend notification + events.emit('ideation:idea-deleted', { + projectPath, + ideaId, + }); + res.json({ success: true }); } catch (error) { logError(error, 'Delete idea failed'); diff --git a/apps/server/src/routes/ideation/routes/ideas-update.ts b/apps/server/src/routes/ideation/routes/ideas-update.ts index c2434ce4..fbf0d8b6 100644 --- a/apps/server/src/routes/ideation/routes/ideas-update.ts +++ b/apps/server/src/routes/ideation/routes/ideas-update.ts @@ -3,11 +3,12 @@ */ import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; import type { IdeationService } from '../../../services/ideation-service.js'; import type { UpdateIdeaInput } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; -export function createIdeasUpdateHandler(ideationService: IdeationService) { +export function createIdeasUpdateHandler(events: EventEmitter, ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { const { projectPath, ideaId, updates } = req.body as { @@ -37,6 +38,13 @@ export function createIdeasUpdateHandler(ideationService: IdeationService) { return; } + // Emit idea updated event for frontend notification + events.emit('ideation:idea-updated', { + projectPath, + ideaId, + idea, + }); + res.json({ success: true, idea }); } catch (error) { logError(error, 'Update idea failed'); diff --git a/apps/server/src/routes/ideation/routes/prompts.ts b/apps/server/src/routes/ideation/routes/prompts.ts index fb54e1dd..8d686bbb 100644 --- a/apps/server/src/routes/ideation/routes/prompts.ts +++ b/apps/server/src/routes/ideation/routes/prompts.ts @@ -26,7 +26,7 @@ export function createPromptsByCategoryHandler(ideationService: IdeationService) try { const { category } = req.params as { category: string }; - const validCategories: IdeaCategory[] = ['feature', 'ux-ui', 'dx', 'growth', 'technical']; + const validCategories = ideationService.getPromptCategories().map((c) => c.id); if (!validCategories.includes(category as IdeaCategory)) { res.status(400).json({ success: false, error: 'Invalid category' }); return; diff --git a/apps/server/src/routes/ideation/routes/session-stop.ts b/apps/server/src/routes/ideation/routes/session-stop.ts index 858d7b7b..c0d59e3b 100644 --- a/apps/server/src/routes/ideation/routes/session-stop.ts +++ b/apps/server/src/routes/ideation/routes/session-stop.ts @@ -3,13 +3,17 @@ */ import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; import type { IdeationService } from '../../../services/ideation-service.js'; import { getErrorMessage, logError } from '../common.js'; -export function createSessionStopHandler(ideationService: IdeationService) { +export function createSessionStopHandler(events: EventEmitter, ideationService: IdeationService) { return async (req: Request, res: Response): Promise => { try { - const { sessionId } = req.body as { sessionId: string }; + const { sessionId, projectPath } = req.body as { + sessionId: string; + projectPath?: string; + }; if (!sessionId) { res.status(400).json({ success: false, error: 'sessionId is required' }); @@ -17,6 +21,15 @@ export function createSessionStopHandler(ideationService: IdeationService) { } await ideationService.stopSession(sessionId); + + // Emit session stopped event for frontend notification + // Note: The service also emits 'ideation:session-ended' internally, + // but we emit here as well for route-level consistency with other routes + events.emit('ideation:session-ended', { + sessionId, + projectPath, + }); + res.json({ success: true }); } catch (error) { logError(error, 'Stop session failed'); diff --git a/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/apps/server/src/routes/ideation/routes/suggestions-generate.ts index 6907b1af..8add2af5 100644 --- a/apps/server/src/routes/ideation/routes/suggestions-generate.ts +++ b/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -5,6 +5,7 @@ import type { Request, Response } from 'express'; import type { IdeationService } from '../../../services/ideation-service.js'; import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; const logger = createLogger('ideation:suggestions-generate'); @@ -45,10 +46,10 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic suggestions, }); } catch (error) { - logger.error('Failed to generate suggestions:', error); + logError(error, 'Failed to generate suggestions'); res.status(500).json({ success: false, - error: (error as Error).message, + error: getErrorMessage(error), }); } }; diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts index 3528c858..7973db05 100644 --- a/apps/server/src/services/ideation-service.ts +++ b/apps/server/src/services/ideation-service.ts @@ -39,6 +39,7 @@ import { ProviderFactory } from '../providers/provider-factory.js'; import type { SettingsService } from './settings-service.js'; import type { FeatureLoader } from './feature-loader.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import { resolveModelString } from '@automaker/model-resolver'; const logger = createLogger('IdeationService'); @@ -200,20 +201,22 @@ export class IdeationService { existingWorkContext ); + // Resolve model alias to canonical identifier + const modelId = resolveModelString(options?.model ?? 'sonnet'); + // Create SDK options const sdkOptions = createChatOptions({ cwd: projectPath, - model: options?.model || 'sonnet', + model: modelId, systemPrompt, abortController: activeSession.abortController!, }); - const effectiveModel = sdkOptions.model!; - const provider = ProviderFactory.getProviderForModel(effectiveModel); + const provider = ProviderFactory.getProviderForModel(modelId); const executeOptions: ExecuteOptions = { prompt: message, - model: effectiveModel, + model: modelId, cwd: projectPath, systemPrompt: sdkOptions.systemPrompt, maxTurns: 1, // Single turn for ideation @@ -645,20 +648,22 @@ export class IdeationService { existingWorkContext ); + // Resolve model alias to canonical identifier + const modelId = resolveModelString('sonnet'); + // Create SDK options const sdkOptions = createChatOptions({ cwd: projectPath, - model: 'sonnet', + model: modelId, systemPrompt, abortController: new AbortController(), }); - const effectiveModel = sdkOptions.model!; - const provider = ProviderFactory.getProviderForModel(effectiveModel); + const provider = ProviderFactory.getProviderForModel(modelId); const executeOptions: ExecuteOptions = { prompt: prompt.prompt, - model: effectiveModel, + model: modelId, cwd: projectPath, systemPrompt: sdkOptions.systemPrompt, maxTurns: 1, @@ -892,6 +897,30 @@ ${contextSection}${existingWorkSection}`; icon: 'Cpu', description: 'Architecture and infrastructure', }, + { + id: 'security', + name: 'Security', + icon: 'Shield', + description: 'Security improvements and vulnerability fixes', + }, + { + id: 'performance', + name: 'Performance', + icon: 'Gauge', + description: 'Performance optimization and speed improvements', + }, + { + id: 'accessibility', + name: 'Accessibility', + icon: 'Accessibility', + description: 'Accessibility features and inclusive design', + }, + { + id: 'analytics', + name: 'Analytics', + icon: 'BarChart', + description: 'Analytics, monitoring, and insights features', + }, ]; } @@ -905,7 +934,8 @@ ${contextSection}${existingWorkSection}`; /** * Get all guided prompts - * NOTE: Keep in sync with apps/ui/src/components/views/ideation-view/data/guided-prompts.ts + * This is the single source of truth for guided prompts data. + * Frontend fetches this data via /api/ideation/prompts endpoint. */ getAllPrompts(): IdeationPrompt[] { return [ @@ -1629,7 +1659,20 @@ Focus on practical, implementable suggestions that would genuinely improve the p return `${summary}. Found ${suggestions.length} improvement opportunities${highPriority > 0 ? ` (${highPriority} high priority)` : ''}.`; } + /** + * Map idea category to feature category + * Used internally for idea-to-feature conversion + */ private mapIdeaCategoryToFeatureCategory(category: IdeaCategory): string { + return this.mapSuggestionCategoryToFeatureCategory(category); + } + + /** + * Map suggestion/idea category to feature category + * This is the single source of truth for category mapping. + * Used by both idea-to-feature conversion and suggestion-to-feature conversion. + */ + mapSuggestionCategoryToFeatureCategory(category: IdeaCategory): string { const mapping: Record = { feature: 'ui', 'ux-ui': 'enhancement', diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx index abf29c83..ccf0de83 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-category-grid.tsx @@ -13,9 +13,10 @@ import { Gauge, Accessibility, BarChart3, + Loader2, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; -import { PROMPT_CATEGORIES } from '../data/guided-prompts'; +import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import type { IdeaCategory } from '@automaker/types'; interface PromptCategoryGridProps { @@ -36,6 +37,8 @@ const iconMap: Record = { }; export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps) { + const { categories, isLoading, error } = useGuidedPrompts(); + return (
@@ -48,30 +51,43 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps Back -
- {PROMPT_CATEGORIES.map((category) => { - const Icon = iconMap[category.icon] || Zap; - return ( - onSelect(category.id)} - > - -
-
- + {isLoading && ( +
+ + Loading categories... +
+ )} + {error && ( +
+

Failed to load categories: {error}

+
+ )} + {!isLoading && !error && ( +
+ {categories.map((category) => { + const Icon = iconMap[category.icon] || Zap; + return ( + onSelect(category.id)} + > + +
+
+ +
+
+

{category.name}

+

{category.description}

+
-
-

{category.name}

-

{category.description}

-
-
- - - ); - })} -
+ + + ); + })} +
+ )}
); diff --git a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx index b9fd1d31..76713350 100644 --- a/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx +++ b/apps/ui/src/components/views/ideation-view/components/prompt-list.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; -import { getPromptsByCategory } from '../data/guided-prompts'; +import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { useIdeationStore } from '@/store/ideation-store'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; @@ -24,6 +24,11 @@ export function PromptList({ category, onBack }: PromptListProps) { const [loadingPromptId, setLoadingPromptId] = useState(null); const [startedPrompts, setStartedPrompts] = useState>(new Set()); const navigate = useNavigate(); + const { + getPromptsByCategory, + isLoading: isLoadingPrompts, + error: promptsError, + } = useGuidedPrompts(); const prompts = getPromptsByCategory(category); @@ -101,60 +106,73 @@ export function PromptList({ category, onBack }: PromptListProps) {
- {prompts.map((prompt) => { - const isLoading = loadingPromptId === prompt.id; - const isGenerating = generatingPromptIds.has(prompt.id); - const isStarted = startedPrompts.has(prompt.id); - const isDisabled = loadingPromptId !== null || isGenerating; + {isLoadingPrompts && ( +
+ + Loading prompts... +
+ )} + {promptsError && ( +
+

Failed to load prompts: {promptsError}

+
+ )} + {!isLoadingPrompts && + !promptsError && + prompts.map((prompt) => { + const isLoading = loadingPromptId === prompt.id; + const isGenerating = generatingPromptIds.has(prompt.id); + const isStarted = startedPrompts.has(prompt.id); + const isDisabled = loadingPromptId !== null || isGenerating; - return ( - !isDisabled && handleSelectPrompt(prompt)} - > - -
-
- {isLoading || isGenerating ? ( - - ) : isStarted ? ( - - ) : ( - - )} + return ( + !isDisabled && handleSelectPrompt(prompt)} + > + +
+
+ {isLoading || isGenerating ? ( + + ) : isStarted ? ( + + ) : ( + + )} +
+
+

{prompt.title}

+

{prompt.description}

+ {(isLoading || isGenerating) && ( +

Generating in dashboard...

+ )} + {isStarted && !isGenerating && ( +

+ Already generated - check dashboard +

+ )} +
-
-

{prompt.title}

-

{prompt.description}

- {(isLoading || isGenerating) && ( -

Generating in dashboard...

- )} - {isStarted && !isGenerating && ( -

- Already generated - check dashboard -

- )} -
-
- - - ); - })} + + + ); + })}
diff --git a/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts b/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts deleted file mode 100644 index 41e8f59c..00000000 --- a/apps/ui/src/components/views/ideation-view/data/guided-prompts.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Guided prompts for ideation sessions - * Static data that provides pre-made prompts for different categories - */ - -import type { IdeaCategory, IdeationPrompt, PromptCategory } from '@automaker/types'; - -export const PROMPT_CATEGORIES: PromptCategory[] = [ - { - id: 'feature', - name: 'Features', - icon: 'Zap', - description: 'New capabilities and functionality', - }, - { - id: 'ux-ui', - name: 'UX/UI', - icon: 'Palette', - description: 'Design and user experience improvements', - }, - { - id: 'dx', - name: 'Developer Experience', - icon: 'Code', - description: 'Developer tooling and workflows', - }, - { - id: 'growth', - name: 'Growth', - icon: 'TrendingUp', - description: 'User engagement and retention', - }, - { - id: 'technical', - name: 'Technical', - icon: 'Cpu', - description: 'Architecture and infrastructure', - }, - { - id: 'security', - name: 'Security', - icon: 'Shield', - description: 'Security and privacy improvements', - }, - { - id: 'performance', - name: 'Performance', - icon: 'Gauge', - description: 'Speed and optimization', - }, - { - id: 'accessibility', - name: 'Accessibility', - icon: 'Accessibility', - description: 'Inclusive design for all users', - }, - { - id: 'analytics', - name: 'Analytics', - icon: 'BarChart3', - description: 'Data insights and tracking', - }, -]; - -export const GUIDED_PROMPTS: IdeationPrompt[] = [ - // Feature prompts - { - id: 'feature-missing', - category: 'feature', - title: 'Missing Features', - description: 'Discover features users might expect', - prompt: - "Analyze this codebase and identify features that users of similar applications typically expect but are missing here. Consider the app's domain, target users, and common patterns in similar products.", - }, - { - id: 'feature-automation', - category: 'feature', - title: 'Automation Opportunities', - description: 'Find manual processes that could be automated', - prompt: - 'Review this codebase and identify manual processes or repetitive tasks that could be automated. Look for patterns where users might be doing things repeatedly that software could handle.', - }, - { - id: 'feature-integrations', - category: 'feature', - title: 'Integration Ideas', - description: 'Identify valuable third-party integrations', - prompt: - "Based on this codebase, what third-party services or APIs would provide value if integrated? Consider the app's domain and what complementary services users might need.", - }, - { - id: 'feature-workflow', - category: 'feature', - title: 'Workflow Improvements', - description: 'Streamline user workflows', - prompt: - 'Analyze the user workflows in this application. What steps could be combined, eliminated, or automated? Where are users likely spending too much time on repetitive tasks?', - }, - - // UX/UI prompts - { - id: 'ux-friction', - category: 'ux-ui', - title: 'Friction Points', - description: 'Identify where users might get stuck', - prompt: - 'Analyze the user flows in this codebase and identify potential friction points. Where might users get confused, stuck, or frustrated? Look at form submissions, navigation, error states, and complex interactions.', - }, - { - id: 'ux-empty-states', - category: 'ux-ui', - title: 'Empty States', - description: 'Improve empty state experiences', - prompt: - "Review the components in this codebase and identify empty states that could be improved. How can we guide users when there's no content? Consider onboarding, helpful prompts, and sample data.", - }, - { - id: 'ux-accessibility', - category: 'ux-ui', - title: 'Accessibility Improvements', - description: 'Enhance accessibility and inclusivity', - prompt: - 'Analyze this codebase for accessibility improvements. Consider keyboard navigation, screen reader support, color contrast, focus states, and ARIA labels. What specific improvements would make this more accessible?', - }, - { - id: 'ux-mobile', - category: 'ux-ui', - title: 'Mobile Experience', - description: 'Optimize for mobile users', - prompt: - 'Review this codebase from a mobile-first perspective. What improvements would enhance the mobile user experience? Consider touch targets, responsive layouts, and mobile-specific interactions.', - }, - { - id: 'ux-feedback', - category: 'ux-ui', - title: 'User Feedback', - description: 'Improve feedback and status indicators', - prompt: - 'Analyze how this application communicates with users. Where are loading states, success messages, or error handling missing or unclear? What feedback would help users understand what is happening?', - }, - - // DX prompts - { - id: 'dx-documentation', - category: 'dx', - title: 'Documentation Gaps', - description: 'Identify missing documentation', - prompt: - 'Review this codebase and identify areas lacking documentation. What would help new developers understand the architecture, APIs, and conventions? Consider inline comments, READMEs, and API docs.', - }, - { - id: 'dx-testing', - category: 'dx', - title: 'Testing Improvements', - description: 'Enhance test coverage and quality', - prompt: - 'Analyze the testing patterns in this codebase. What areas need better test coverage? What types of tests are missing? Consider unit tests, integration tests, and end-to-end tests.', - }, - { - id: 'dx-tooling', - category: 'dx', - title: 'Developer Tooling', - description: 'Improve development workflows', - prompt: - 'Review the development setup and tooling in this codebase. What improvements would speed up development? Consider build times, hot reload, debugging tools, and developer scripts.', - }, - { - id: 'dx-error-handling', - category: 'dx', - title: 'Error Handling', - description: 'Improve error messages and debugging', - prompt: - 'Analyze error handling in this codebase. Where are error messages unclear or missing? What would help developers debug issues faster? Consider logging, error boundaries, and stack traces.', - }, - - // Growth prompts - { - id: 'growth-onboarding', - category: 'growth', - title: 'Onboarding Flow', - description: 'Improve new user experience', - prompt: - "Analyze this application's onboarding experience. How can we help new users understand the value and get started quickly? Consider tutorials, progressive disclosure, and quick wins.", - }, - { - id: 'growth-engagement', - category: 'growth', - title: 'User Engagement', - description: 'Increase user retention and activity', - prompt: - 'Review this application and suggest features that would increase user engagement and retention. What would bring users back daily? Consider notifications, streaks, social features, and personalization.', - }, - { - id: 'growth-sharing', - category: 'growth', - title: 'Shareability', - description: 'Make the app more shareable', - prompt: - 'How can this application be made more shareable? What features would encourage users to invite others or share their work? Consider collaboration, public profiles, and export features.', - }, - { - id: 'growth-monetization', - category: 'growth', - title: 'Monetization Ideas', - description: 'Identify potential revenue streams', - prompt: - 'Based on this codebase, what features or tiers could support monetization? Consider premium features, usage limits, team features, and integrations that users would pay for.', - }, - - // Technical prompts - { - id: 'tech-performance', - category: 'technical', - title: 'Performance Optimization', - description: 'Identify performance bottlenecks', - prompt: - 'Analyze this codebase for performance optimization opportunities. Where are the likely bottlenecks? Consider database queries, API calls, bundle size, rendering, and caching strategies.', - }, - { - id: 'tech-architecture', - category: 'technical', - title: 'Architecture Review', - description: 'Evaluate and improve architecture', - prompt: - 'Review the architecture of this codebase. What improvements would make it more maintainable, scalable, or testable? Consider separation of concerns, dependency management, and patterns.', - }, - { - id: 'tech-debt', - category: 'technical', - title: 'Technical Debt', - description: 'Identify areas needing refactoring', - prompt: - 'Identify technical debt in this codebase. What areas are becoming hard to maintain or understand? What refactoring would have the highest impact? Consider duplicated code, complexity, and outdated patterns.', - }, - { - id: 'tech-security', - category: 'technical', - title: 'Security Review', - description: 'Identify security improvements', - prompt: - 'Review this codebase for security improvements. What best practices are missing? Consider authentication, authorization, input validation, and data protection. Note: This is for improvement suggestions, not a security audit.', - }, - - // Security prompts - { - id: 'security-auth', - category: 'security', - title: 'Authentication Security', - description: 'Review authentication mechanisms', - prompt: - 'Analyze the authentication system in this codebase. What security improvements would strengthen user authentication? Consider password policies, session management, MFA, and token handling.', - }, - { - id: 'security-data', - category: 'security', - title: 'Data Protection', - description: 'Protect sensitive user data', - prompt: - 'Review how this application handles sensitive data. What improvements would better protect user privacy? Consider encryption, data minimization, secure storage, and data retention policies.', - }, - { - id: 'security-input', - category: 'security', - title: 'Input Validation', - description: 'Prevent injection attacks', - prompt: - 'Analyze input handling in this codebase. Where could input validation be strengthened? Consider SQL injection, XSS, command injection, and file upload vulnerabilities.', - }, - { - id: 'security-api', - category: 'security', - title: 'API Security', - description: 'Secure API endpoints', - prompt: - 'Review the API security in this codebase. What improvements would make the API more secure? Consider rate limiting, authorization, CORS, and request validation.', - }, - - // Performance prompts - { - id: 'perf-frontend', - category: 'performance', - title: 'Frontend Performance', - description: 'Optimize UI rendering and loading', - prompt: - 'Analyze the frontend performance of this application. What optimizations would improve load times and responsiveness? Consider bundle splitting, lazy loading, memoization, and render optimization.', - }, - { - id: 'perf-backend', - category: 'performance', - title: 'Backend Performance', - description: 'Optimize server-side operations', - prompt: - 'Review backend performance in this codebase. What optimizations would improve response times? Consider database queries, caching strategies, async operations, and resource pooling.', - }, - { - id: 'perf-database', - category: 'performance', - title: 'Database Optimization', - description: 'Improve query performance', - prompt: - 'Analyze database interactions in this codebase. What optimizations would improve data access performance? Consider indexing, query optimization, denormalization, and connection pooling.', - }, - { - id: 'perf-caching', - category: 'performance', - title: 'Caching Strategies', - description: 'Implement effective caching', - prompt: - 'Review caching opportunities in this application. Where would caching provide the most benefit? Consider API responses, computed values, static assets, and session data.', - }, - - // Accessibility prompts - { - id: 'a11y-keyboard', - category: 'accessibility', - title: 'Keyboard Navigation', - description: 'Enable full keyboard access', - prompt: - 'Analyze keyboard accessibility in this codebase. What improvements would enable users to navigate entirely with keyboard? Consider focus management, tab order, and keyboard shortcuts.', - }, - { - id: 'a11y-screen-reader', - category: 'accessibility', - title: 'Screen Reader Support', - description: 'Improve screen reader experience', - prompt: - 'Review screen reader compatibility in this application. What improvements would help users with visual impairments? Consider ARIA labels, semantic HTML, live regions, and alt text.', - }, - { - id: 'a11y-visual', - category: 'accessibility', - title: 'Visual Accessibility', - description: 'Improve visual design for all users', - prompt: - 'Analyze visual accessibility in this codebase. What improvements would help users with visual impairments? Consider color contrast, text sizing, focus indicators, and reduced motion.', - }, - { - id: 'a11y-forms', - category: 'accessibility', - title: 'Accessible Forms', - description: 'Make forms usable for everyone', - prompt: - 'Review form accessibility in this application. What improvements would make forms more accessible? Consider labels, error messages, required field indicators, and input assistance.', - }, - - // Analytics prompts - { - id: 'analytics-tracking', - category: 'analytics', - title: 'User Tracking', - description: 'Track key user behaviors', - prompt: - 'Analyze this application for analytics opportunities. What user behaviors should be tracked to understand engagement? Consider page views, feature usage, conversion funnels, and session duration.', - }, - { - id: 'analytics-metrics', - category: 'analytics', - title: 'Key Metrics', - description: 'Define success metrics', - prompt: - 'Based on this codebase, what key metrics should be tracked? Consider user acquisition, retention, engagement, and feature adoption. What dashboards would be most valuable?', - }, - { - id: 'analytics-errors', - category: 'analytics', - title: 'Error Monitoring', - description: 'Track and analyze errors', - prompt: - 'Review error handling in this codebase for monitoring opportunities. What error tracking would help identify and fix issues faster? Consider error aggregation, alerting, and stack traces.', - }, - { - id: 'analytics-performance', - category: 'analytics', - title: 'Performance Monitoring', - description: 'Track application performance', - prompt: - 'Analyze this application for performance monitoring opportunities. What metrics would help identify bottlenecks? Consider load times, API response times, and resource usage.', - }, -]; - -export function getPromptsByCategory(category: IdeaCategory): IdeationPrompt[] { - return GUIDED_PROMPTS.filter((p) => p.category === category); -} - -export function getPromptById(id: string): IdeationPrompt | undefined { - return GUIDED_PROMPTS.find((p) => p.id === id); -} - -export function getCategoryById(id: IdeaCategory): PromptCategory | undefined { - return PROMPT_CATEGORIES.find((c) => c.id === id); -} diff --git a/apps/ui/src/components/views/ideation-view/index.tsx b/apps/ui/src/components/views/ideation-view/index.tsx index aa962ae8..fd4ad245 100644 --- a/apps/ui/src/components/views/ideation-view/index.tsx +++ b/apps/ui/src/components/views/ideation-view/index.tsx @@ -9,27 +9,12 @@ import { useAppStore } from '@/store/app-store'; import { PromptCategoryGrid } from './components/prompt-category-grid'; import { PromptList } from './components/prompt-list'; import { IdeationDashboard } from './components/ideation-dashboard'; -import { getCategoryById } from './data/guided-prompts'; +import { useGuidedPrompts } from '@/hooks/use-guided-prompts'; import { Button } from '@/components/ui/button'; import { ArrowLeft, ChevronRight, Lightbulb } from 'lucide-react'; import type { IdeaCategory } from '@automaker/types'; import type { IdeationMode } from '@/store/ideation-store'; -// Get subtitle text based on current mode -function getSubtitle(currentMode: IdeationMode, selectedCategory: IdeaCategory | null): string { - if (currentMode === 'dashboard') { - return 'Review and accept generated ideas'; - } - if (currentMode === 'prompts') { - if (selectedCategory) { - const categoryInfo = getCategoryById(selectedCategory); - return `Select a prompt from ${categoryInfo?.name || 'category'}`; - } - return 'Select a category to generate ideas'; - } - return ''; -} - // Breadcrumb component - compact inline breadcrumbs function IdeationBreadcrumbs({ currentMode, @@ -40,6 +25,7 @@ function IdeationBreadcrumbs({ selectedCategory: IdeaCategory | null; onNavigate: (mode: IdeationMode, category?: IdeaCategory | null) => void; }) { + const { getCategoryById } = useGuidedPrompts(); const categoryInfo = selectedCategory ? getCategoryById(selectedCategory) : null; // On dashboard, no breadcrumbs needed (it's the root) @@ -88,9 +74,26 @@ function IdeationHeader({ onGenerateIdeas: () => void; onBack: () => void; }) { - const subtitle = getSubtitle(currentMode, selectedCategory); + const { getCategoryById } = useGuidedPrompts(); const showBackButton = currentMode === 'prompts'; + // Get subtitle text based on current mode + const getSubtitle = (): string => { + if (currentMode === 'dashboard') { + return 'Review and accept generated ideas'; + } + if (currentMode === 'prompts') { + if (selectedCategory) { + const categoryInfo = getCategoryById(selectedCategory); + return `Select a prompt from ${categoryInfo?.name || 'category'}`; + } + return 'Select a category to generate ideas'; + } + return ''; + }; + + const subtitle = getSubtitle(); + return (
diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index 8f2264d6..8a354b3d 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -1,6 +1,7 @@ export { useAutoMode } from './use-auto-mode'; export { useBoardBackgroundSettings } from './use-board-background-settings'; export { useElectronAgent } from './use-electron-agent'; +export { useGuidedPrompts } from './use-guided-prompts'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useMessageQueue } from './use-message-queue'; export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection'; diff --git a/apps/ui/src/hooks/use-guided-prompts.ts b/apps/ui/src/hooks/use-guided-prompts.ts new file mode 100644 index 00000000..e192d6b3 --- /dev/null +++ b/apps/ui/src/hooks/use-guided-prompts.ts @@ -0,0 +1,86 @@ +/** + * Hook for fetching guided prompts from the backend API + * + * This hook provides the single source of truth for guided prompts, + * fetched from the backend /api/ideation/prompts endpoint. + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { IdeationPrompt, PromptCategory, IdeaCategory } from '@automaker/types'; +import { getElectronAPI } from '@/lib/electron'; + +interface UseGuidedPromptsReturn { + prompts: IdeationPrompt[]; + categories: PromptCategory[]; + isLoading: boolean; + error: string | null; + refetch: () => Promise; + getPromptsByCategory: (category: IdeaCategory) => IdeationPrompt[]; + getPromptById: (id: string) => IdeationPrompt | undefined; + getCategoryById: (id: IdeaCategory) => PromptCategory | undefined; +} + +export function useGuidedPrompts(): UseGuidedPromptsReturn { + const [prompts, setPrompts] = useState([]); + const [categories, setCategories] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPrompts = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const api = getElectronAPI(); + const result = await api.ideation?.getPrompts(); + + if (result?.success) { + setPrompts(result.prompts || []); + setCategories(result.categories || []); + } else { + setError(result?.error || 'Failed to fetch prompts'); + } + } catch (err) { + console.error('Failed to fetch guided prompts:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch prompts'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchPrompts(); + }, [fetchPrompts]); + + const getPromptsByCategory = useCallback( + (category: IdeaCategory): IdeationPrompt[] => { + return prompts.filter((p) => p.category === category); + }, + [prompts] + ); + + const getPromptById = useCallback( + (id: string): IdeationPrompt | undefined => { + return prompts.find((p) => p.id === id); + }, + [prompts] + ); + + const getCategoryById = useCallback( + (id: IdeaCategory): PromptCategory | undefined => { + return categories.find((c) => c.id === id); + }, + [categories] + ); + + return { + prompts, + categories, + isLoading, + error, + refetch: fetchPrompts, + getPromptsByCategory, + getPromptById, + getCategoryById, + }; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index ef9c6bb9..d81b46b6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -17,6 +17,8 @@ import type { IdeaCategory, IdeationSession, IdeationMessage, + IdeationPrompt, + PromptCategory, ProjectAnalysisResult, AnalysisSuggestion, StartSessionOptions, @@ -46,6 +48,8 @@ export type { IdeaCategory, IdeationSession, IdeationMessage, + IdeationPrompt, + PromptCategory, ProjectAnalysisResult, AnalysisSuggestion, StartSessionOptions, @@ -123,6 +127,14 @@ export interface IdeationAPI { suggestion: AnalysisSuggestion ) => Promise<{ success: boolean; featureId?: string; error?: string }>; + // Get guided prompts (single source of truth from backend) + getPrompts: () => Promise<{ + success: boolean; + prompts?: IdeationPrompt[]; + categories?: PromptCategory[]; + error?: string; + }>; + // Event subscriptions onStream: (callback: (event: any) => void) => () => void; onAnalysisEvent: (callback: (event: any) => void) => () => void; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 7464d55a..a76e2549 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1690,35 +1690,13 @@ export class HttpApiClient implements ElectronAPI { convertToFeature: (projectPath: string, ideaId: string, options?: ConvertToFeatureOptions) => this.post('/api/ideation/convert', { projectPath, ideaId, ...options }), - addSuggestionToBoard: async (projectPath: string, suggestion: AnalysisSuggestion) => { - // Create a feature directly from the suggestion - const result = await this.post<{ success: boolean; feature?: Feature; error?: string }>( - '/api/features/create', - { - projectPath, - feature: { - title: suggestion.title, - description: - suggestion.description + - (suggestion.rationale ? `\n\n**Rationale:** ${suggestion.rationale}` : ''), - category: - suggestion.category === 'ux-ui' - ? 'enhancement' - : suggestion.category === 'dx' - ? 'chore' - : suggestion.category === 'technical' - ? 'refactor' - : 'feature', - status: 'backlog', - }, - } - ); - return { - success: result.success, - featureId: result.feature?.id, - error: result.error, - }; - }, + addSuggestionToBoard: ( + projectPath: string, + suggestion: AnalysisSuggestion + ): Promise<{ success: boolean; featureId?: string; error?: string }> => + this.post('/api/ideation/add-suggestion', { projectPath, suggestion }), + + getPrompts: () => this.get('/api/ideation/prompts'), onStream: (callback: (event: any) => void): (() => void) => { return this.subscribeToEvent('ideation:stream', callback as EventCallback); diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 091b3e90..6692f0f0 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -35,6 +35,10 @@ export type EventType = | 'ideation:analysis-progress' | 'ideation:analysis-complete' | 'ideation:analysis-error' - | 'ideation:suggestions'; + | 'ideation:suggestions' + | 'ideation:idea-created' + | 'ideation:idea-updated' + | 'ideation:idea-deleted' + | 'ideation:idea-converted'; export type EventCallback = (type: EventType, payload: unknown) => void; From e2206d7a96c9489eb3425ab660b2847c8d52eb91 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sun, 4 Jan 2026 01:56:45 -0500 Subject: [PATCH 17/17] feat: add thorough verification process and enhance agent output modal - Introduced a new markdown file outlining a mandatory 3-pass verification process for code completion, focusing on correctness, edge cases, and maintainability. - Updated the AgentInfoPanel to display a todo list for non-backlog features, ensuring users can see the agent's current tasks. - Enhanced the AgentOutputModal to support a summary view, extracting and displaying summary content from raw log output. - Improved the log parser to extract summaries from various formats, enhancing the overall user experience and information accessibility. --- .claude/commands/thorough.md | 45 ++++++ .../kanban-card/agent-info-panel.tsx | 39 ++++++ .../board-view/dialogs/agent-output-modal.tsx | 48 +++++-- apps/ui/src/lib/agent-context-parser.ts | 129 +++++++++++++----- apps/ui/src/lib/log-parser.ts | 47 +++++++ 5 files changed, 264 insertions(+), 44 deletions(-) create mode 100644 .claude/commands/thorough.md diff --git a/.claude/commands/thorough.md b/.claude/commands/thorough.md new file mode 100644 index 00000000..c69ada0f --- /dev/null +++ b/.claude/commands/thorough.md @@ -0,0 +1,45 @@ +When you think you are done, you are NOT done. + +You must run a mandatory 3-pass verification before concluding: + +## Pass 1: Correctness & Functionality + +- [ ] Verify logic matches requirements and specifications +- [ ] Check type safety (TypeScript types are correct and complete) +- [ ] Ensure imports are correct and follow project conventions +- [ ] Verify all functions/classes work as intended +- [ ] Check that return values and side effects are correct +- [ ] Run relevant tests if they exist, or verify testability +- [ ] Confirm integration with existing code works properly + +## Pass 2: Edge Cases & Safety + +- [ ] Handle null/undefined inputs gracefully +- [ ] Validate all user inputs and external data +- [ ] Check error handling (try/catch, error boundaries, etc.) +- [ ] Verify security considerations (no sensitive data exposure, proper auth checks) +- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.) +- [ ] Ensure resource cleanup (file handles, connections, timers) +- [ ] Check for potential race conditions or async issues +- [ ] Verify file path security (no directory traversal vulnerabilities) + +## Pass 3: Maintainability & Code Quality + +- [ ] Code follows project style guide and conventions +- [ ] Functions/classes are single-purpose and well-named +- [ ] Remove dead code, unused imports, and console.logs +- [ ] Extract magic numbers/strings into named constants +- [ ] Check for code duplication (DRY principle) +- [ ] Verify appropriate abstraction levels (not over/under-engineered) +- [ ] Add necessary comments for complex logic +- [ ] Ensure consistent error messages and logging +- [ ] Check that code is readable and self-documenting +- [ ] Verify proper separation of concerns + +**For each pass, explicitly report:** + +- What you checked +- Any issues found and how they were fixed +- Any remaining concerns or trade-offs + +Only after completing all three passes with explicit findings may you conclude the work is done. diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index b36dea20..1fbe6ee1 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -255,6 +255,45 @@ export function AgentInfoPanel({ ); } + // Show just the todo list for non-backlog features when showAgentInfo is false + // This ensures users always see what the agent is working on + if (!showAgentInfo && feature.status !== 'backlog' && agentInfo && agentInfo.todos.length > 0) { + return ( +
+
+ + + {agentInfo.todos.filter((t) => t.status === 'completed').length}/ + {agentInfo.todos.length} tasks + +
+
+ {agentInfo.todos.map((todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + + )} + + {todo.content} + +
+ ))} +
+
+ ); + } + // Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet) // This ensures the dialog can be opened from the expand button return ( diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index 58fe3ad6..5124f7af 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import { Dialog, DialogContent, @@ -6,12 +6,14 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Loader2, List, FileText, GitBranch } from 'lucide-react'; +import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { LogViewer } from '@/components/ui/log-viewer'; import { GitDiffPanel } from '@/components/ui/git-diff-panel'; import { TaskProgressPanel } from '@/components/ui/task-progress-panel'; +import { Markdown } from '@/components/ui/markdown'; import { useAppStore } from '@/store/app-store'; +import { extractSummary } from '@/lib/log-parser'; import type { AutoModeEvent } from '@/types/electron'; interface AgentOutputModalProps { @@ -27,7 +29,7 @@ interface AgentOutputModalProps { projectPath?: string; } -type ViewMode = 'parsed' | 'raw' | 'changes'; +type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes'; export function AgentOutputModal({ open, @@ -40,8 +42,14 @@ export function AgentOutputModal({ }: AgentOutputModalProps) { const [output, setOutput] = useState(''); const [isLoading, setIsLoading] = useState(true); - const [viewMode, setViewMode] = useState('parsed'); + const [viewMode, setViewMode] = useState(null); const [projectPath, setProjectPath] = useState(''); + + // Extract summary from output + const summary = useMemo(() => extractSummary(output), [output]); + + // Determine the effective view mode - default to summary if available, otherwise parsed + const effectiveViewMode = viewMode ?? (summary ? 'summary' : 'parsed'); const scrollRef = useRef(null); const autoScrollRef = useRef(true); const projectPathRef = useRef(''); @@ -299,8 +307,8 @@ export function AgentOutputModal({ className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col" data-testid="agent-output-modal" > - -
+ +
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && ( @@ -308,10 +316,24 @@ export function AgentOutputModal({ Agent Output
+ {summary && ( + + )}