Merge v0.8.0rc into feat/cursor-cli

Resolved conflicts:
- sdk-options.ts: kept HEAD (MCP & thinking level features)
- auto-mode-service.ts: kept HEAD (MCP features + fallback code)
- agent-output-modal.tsx: used v0.8.0rc (effectiveViewMode + pr-8 spacing)
- feature-suggestions-dialog.tsx: accepted deletion
- electron.ts: used v0.8.0rc (Ideation types)
- package-lock.json: regenerated

Fixed sdk-options.test.ts to expect 'default' permissionMode for read-only operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2026-01-04 13:12:45 +01:00
81 changed files with 6933 additions and 1958 deletions

1
.claude/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
hans/

View File

@@ -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.

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

1
.gitignore vendored
View File

@@ -81,6 +81,7 @@ blob-report/
docker-compose.override.yml
.claude/docker-compose.override.yml
.claude/hans/
pnpm-lock.yaml
yarn.lock

View File

@@ -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

View File

@@ -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

17
TODO.md Normal file
View File

@@ -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.

View File

@@ -61,6 +61,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();
@@ -165,6 +167,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 () => {
@@ -218,6 +221,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(events, ideationService, featureLoader));
// Create HTTP server
const server = createServer(app);

View File

@@ -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.

View File

@@ -70,20 +70,13 @@ export class ClaudeProvider extends BaseProvider {
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// 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,
@@ -95,10 +88,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

View File

@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
permissionMode: 'default',
},
});

View File

@@ -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);

View File

@@ -0,0 +1,109 @@
/**
* Ideation routes - HTTP API for brainstorming and idea management
*/
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';
// 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 { 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 {
const router = Router();
// Session management
router.post(
'/session/start',
validatePathParams('projectPath'),
createSessionStartHandler(ideationService)
);
router.post('/session/message', createSessionMessageHandler(ideationService));
router.post('/session/stop', createSessionStopHandler(events, 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(events, ideationService)
);
router.post(
'/ideas/get',
validatePathParams('projectPath'),
createIdeasGetHandler(ideationService)
);
router.post(
'/ideas/update',
validatePathParams('projectPath'),
createIdeasUpdateHandler(events, ideationService)
);
router.post(
'/ideas/delete',
validatePathParams('projectPath'),
createIdeasDeleteHandler(events, 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(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)
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;
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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<void> => {
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) });
}
};
}

View File

@@ -0,0 +1,77 @@
/**
* POST /convert - Convert an idea to a feature
*/
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
) {
return async (req: Request, res: Response): Promise<void> => {
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);
// 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) {
logError(error, 'Convert to feature failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,51 @@
/**
* POST /ideas/create - Create a new idea
*/
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(events: EventEmitter, ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
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);
// 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');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /ideas/delete - Delete an idea
*/
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(events: EventEmitter, ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
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);
// 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');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -0,0 +1,54 @@
/**
* POST /ideas/update - Update an idea
*/
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(events: EventEmitter, ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
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;
}
// 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');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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<void> => {
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<void> => {
try {
const { category } = req.params as { category: string };
const validCategories = ideationService.getPromptCategories().map((c) => c.id);
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /session/stop - Stop an ideation session
*/
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(events: EventEmitter, ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sessionId, projectPath } = req.body as {
sessionId: string;
projectPath?: string;
};
if (!sessionId) {
res.status(400).json({ success: false, error: 'sessionId is required' });
return;
}
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');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,56 @@
/**
* 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';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('ideation:suggestions-generate');
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
return async (req: Request, res: Response): Promise<void> => {
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) {
logError(error, 'Failed to generate suggestions');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -23,7 +23,6 @@ import {
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
@@ -242,9 +241,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,
@@ -274,8 +270,6 @@ export class AgentService {
enableSandboxMode,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
@@ -300,8 +294,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

File diff suppressed because it is too large Load Diff

View File

@@ -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
});
});
});

View File

@@ -73,7 +73,8 @@ describe('claude-provider.ts', () => {
maxTurns: 10,
cwd: '/test/dir',
allowedTools: ['Read', 'Write'],
permissionMode: 'default',
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
}),
});
});

View File

@@ -0,0 +1,788 @@
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';
// 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', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@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(() => ({
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 (already mocked above, but reset return values)
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: 'feature',
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 () => {
// 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();
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('name');
});
});
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');
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { RouterProvider } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { router } from './utils/router';
@@ -19,6 +19,19 @@ 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(() => {
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)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {

View File

@@ -16,7 +16,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 {
@@ -65,12 +66,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);

View File

@@ -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,
},
],
});

View File

@@ -6,7 +6,7 @@ const logger = createLogger('DescriptionImageDropZone');
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,
@@ -97,9 +97,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]
);

View File

@@ -90,6 +90,9 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
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<keyof KeyboardShortcuts, 'navigation' | 'ui' |
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
ideation: 'navigation',
githubIssues: 'navigation',
githubPrs: 'navigation',
toggleSidebar: 'ui',
addFeature: 'action',
addContextFile: 'action',

View File

@@ -34,7 +34,6 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
EditFeatureDialog,
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
@@ -57,7 +56,6 @@ import {
useBoardBackground,
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
@@ -156,19 +154,6 @@ export function BoardView() {
handleFollowUpDialogChange,
} = useFollowUpState();
// Suggestions state hook
const {
showSuggestionsDialog,
suggestionsCount,
featureSuggestions,
isGeneratingSuggestions,
setShowSuggestionsDialog,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
updateSuggestions,
closeSuggestionsDialog,
} = useSuggestionsState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -203,9 +188,6 @@ export function BoardView() {
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features: hookFeatures,
isLoading,
@@ -1122,8 +1104,6 @@ export function BoardView() {
runningAutoTasks={runningAutoTasks}
shortcuts={shortcuts}
onStartNextFeatures={handleStartNextFeatures}
onShowSuggestions={() => setShowSuggestionsDialog(true)}
suggestionsCount={suggestionsCount}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
@@ -1272,17 +1252,6 @@ export function BoardView() {
isMaximized={isMaximized}
/>
{/* Feature Suggestions Dialog */}
<FeatureSuggestionsDialog
open={showSuggestionsDialog}
onClose={closeSuggestionsDialog}
projectPath={currentProject.path}
suggestions={featureSuggestions}
setSuggestions={updateSuggestions}
isGenerating={isGeneratingSuggestions}
setIsGenerating={setIsGeneratingSuggestions}
/>
{/* Backlog Plan Dialog */}
<BacklogPlanDialog
open={showPlanDialog}

View File

@@ -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 (
<div className="mb-3 space-y-1 overflow-hidden">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-24 overflow-y-auto">
{agentInfo.todos.map((todo, idx) => (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
</span>
</div>
))}
</div>
</div>
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useEffect, useRef, useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
@@ -7,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 {
@@ -28,9 +29,7 @@ interface AgentOutputModalProps {
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
const logger = createLogger('AgentOutputModal');
type ViewMode = 'summary' | 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -43,8 +42,14 @@ export function AgentOutputModal({
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
const [projectPath, setProjectPath] = useState<string>('');
// 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<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
@@ -91,7 +96,7 @@ export function AgentOutputModal({
setOutput('');
}
} catch (error) {
logger.error('Failed to load output:', error);
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
@@ -108,11 +113,11 @@ export function AgentOutputModal({
const api = getElectronAPI();
if (!api?.autoMode) return;
logger.info('Subscribing to events for featureId:', featureId);
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
const unsubscribe = api.autoMode.onEvent((event) => {
logger.debug(
'Received event:',
console.log(
'[AgentOutputModal] Received event:',
event.type,
'featureId:',
'featureId' in event ? event.featureId : 'none',
@@ -122,7 +127,7 @@ export function AgentOutputModal({
// Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) {
logger.debug('Skipping event - featureId mismatch');
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
return;
}
@@ -299,11 +304,11 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col overflow-hidden min-h-0 gap-3"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
@@ -311,10 +316,24 @@ export function AgentOutputModal({
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-summary"
>
<ClipboardList className="w-3.5 h-3.5" />
Summary
</button>
)}
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -326,7 +345,7 @@ export function AgentOutputModal({
<button
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'changes'
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -338,7 +357,7 @@ export function AgentOutputModal({
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
@@ -350,7 +369,7 @@ export function AgentOutputModal({
</div>
</div>
<DialogDescription
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
className="mt-1 max-h-24 overflow-y-auto break-words"
data-testid="agent-output-description"
>
{featureDescription}
@@ -361,12 +380,11 @@ export function AgentOutputModal({
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="shrink-0 rounded-lg"
defaultExpanded={false}
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible">
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
@@ -382,12 +400,16 @@ export function AgentOutputModal({
</div>
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
<>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -398,14 +420,14 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === 'parsed' ? (
) : effectiveViewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap wrap-break-word text-zinc-300">{output}</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center shrink-0">
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}

View File

@@ -1,599 +0,0 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Loader2,
Lightbulb,
Download,
StopCircle,
ChevronDown,
ChevronRight,
RefreshCw,
Shield,
Zap,
List,
FileText,
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
import { useModelOverride } from '@/components/shared/use-model-override';
import { ModelOverrideTrigger } from '@/components/shared/model-override-trigger';
const logger = createLogger('FeatureSuggestions');
interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => 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<string[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const { features, setFeatures } = useAppStore();
// Model override for suggestions
const { effectiveModelEntry, isOverridden, setOverride } = useModelOverride({
phase: 'suggestionsModel',
});
// 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 {
// Pass model and thinkingLevel from the effective model entry
const result = await api.suggestions.generate(
projectPath,
suggestionType,
effectiveModelEntry.model,
effectiveModelEntry.thinkingLevel
);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
logger.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
setIsGenerating(false);
}
},
[projectPath, setIsGenerating, setSuggestions, effectiveModelEntry]
);
// 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) {
logger.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) {
logger.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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[70vw] max-w-[70vw] max-h-[85vh] flex flex-col"
data-testid="feature-suggestions-dialog"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
{currentConfig ? (
<>
<currentConfig.icon className={`w-5 h-5 ${currentConfig.color}`} />
{currentConfig.label}
</>
) : (
<>
<Lightbulb className="w-5 h-5 text-yellow-500" />
AI Suggestions
</>
)}
<ModelOverrideTrigger
currentModelEntry={effectiveModelEntry}
onModelChange={setOverride}
phase="suggestionsModel"
isOverridden={isOverridden}
size="sm"
variant="icon"
/>
</DialogTitle>
<DialogDescription>
{currentConfig
? currentConfig.description
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
</DialogDescription>
</DialogHeader>
{!hasStarted ? (
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
</div>
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</div>
</div>
) : isGenerating ? (
// Generating state - show progress
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Analyzing project...
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" />
Stop
</Button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
</div>
)}
</div>
</div>
) : hasSuggestions ? (
// Results state - show suggestions list
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-2 min-h-[200px] max-h-[400px] pr-2"
>
{suggestions.map((suggestion) => {
const isSelected = selectedIds.has(suggestion.id);
const isExpanded = expandedIds.has(suggestion.id);
return (
<div
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
data-testid={`suggestion-${suggestion.id}`}
>
<div className="flex items-start gap-3">
<Checkbox
id={suggestion.id}
checked={isSelected}
onCheckedChange={() => toggleSelection(suggestion.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleExpanded(suggestion.id)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
#{suggestion.priority}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{suggestion.category}
</span>
</div>
<Label
htmlFor={suggestion.id}
className="text-sm font-medium cursor-pointer"
>
{suggestion.description}
</Label>
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
) : (
// No results state
<div className="flex-1 flex flex-col items-center justify-center py-8 text-center">
<p className="text-muted-foreground mb-4">
No suggestions were generated. Try running the analysis again.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back to Selection
</Button>
{currentSuggestionType && (
<Button onClick={() => handleGenerate(currentSuggestionType)}>
<Lightbulb className="w-4 h-4 mr-2" />
Try Again
</Button>
)}
</div>
</div>
)}
<DialogFooter className="flex-shrink-0">
{hasSuggestions && (
<div className="flex gap-2 w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleBackToSelection}>
Back
</Button>
{currentSuggestionType && (
<Button variant="outline" onClick={() => handleGenerate(currentSuggestionType)}>
{currentConfig && <currentConfig.icon className="w-4 h-4 mr-2" />}
Regenerate
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? 's' : ''}
</HotkeyButton>
</div>
</div>
)}
{!hasSuggestions && !isGenerating && hasStarted && (
<Button variant="ghost" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',

View File

@@ -9,9 +9,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<boolean>;
features: any[];
isLoading: boolean;
@@ -23,9 +20,6 @@ export function useBoardEffects({
currentProject,
specCreatingForProject,
setSpecCreatingForProject,
setSuggestionsCount,
setFeatureSuggestions,
setIsGeneratingSuggestions,
checkContextExists,
features,
isLoading,
@@ -47,26 +41,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();

View File

@@ -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<FeatureSuggestion[]>([]);
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,
};
}

View File

@@ -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<typeof useKeyboardShortcutsConfig>;
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
</Button>
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={onShowSuggestions}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={onStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)}
</div>
<FastForward className="w-3 h-3 mr-1" />
Make
</HotkeyButton>
)
) : column.id === 'in_progress' ? (
<Button
variant="ghost"

View File

@@ -0,0 +1,340 @@
/**
* IdeationDashboard - Main dashboard showing all generated suggestions
* First page users see - shows all ideas ready for accept/reject
*/
import { useState, useMemo } from 'react';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useIdeationStore, type GenerationJob } from '@/store/ideation-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { AnalysisSuggestion } from '@automaker/types';
interface IdeationDashboardProps {
onGenerateIdeas: () => void;
}
function SuggestionCard({
suggestion,
job,
onAccept,
onRemove,
isAdding,
}: {
suggestion: AnalysisSuggestion;
job: GenerationJob;
onAccept: () => void;
onRemove: () => void;
isAdding: boolean;
}) {
return (
<Card className="transition-all hover:border-primary/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{suggestion.title}</h4>
<Badge variant="outline" className="text-xs">
{suggestion.priority}
</Badge>
<Badge variant="secondary" className="text-xs">
{job.prompt.title}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.description}</p>
{suggestion.rationale && (
<p className="text-xs text-muted-foreground mt-2 italic">{suggestion.rationale}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={onRemove}
disabled={isAdding}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
<Button size="sm" onClick={onAccept} disabled={isAdding} className="gap-1">
{isAdding ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Plus className="w-4 h-4" />
Accept
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function GeneratingCard({ job }: { job: GenerationJob }) {
const { removeJob } = useIdeationStore();
const isError = job.status === 'error';
return (
<Card className={cn('transition-all', isError ? 'border-red-500/50' : 'border-blue-500/50')}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{isError ? (
<AlertCircle className="w-5 h-5 text-red-500" />
) : (
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
)}
<div>
<p className="font-medium">{job.prompt.title}</p>
<p className="text-sm text-muted-foreground">
{isError ? job.error || 'Failed to generate' : 'Generating ideas...'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeJob(job.id)}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function TagFilter({
tags,
tagCounts,
selectedTags,
onToggleTag,
}: {
tags: string[];
tagCounts: Record<string, number>;
selectedTags: Set<string>;
onToggleTag: (tag: string) => void;
}) {
if (tags.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isSelected = selectedTags.has(tag);
const count = tagCounts[tag] || 0;
return (
<button
key={tag}
onClick={() => onToggleTag(tag)}
className={cn(
'px-3 py-1.5 text-sm rounded-full border transition-all flex items-center gap-1.5',
isSelected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-secondary/50 text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
)}
>
{tag}
<span
className={cn(
'text-xs',
isSelected ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
)}
>
({count})
</span>
</button>
);
})}
{selectedTags.size > 0 && (
<button
onClick={() => selectedTags.forEach((tag) => onToggleTag(tag))}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Clear filters
</button>
)}
</div>
);
}
export function IdeationDashboard({ onGenerateIdeas }: IdeationDashboardProps) {
const currentProject = useAppStore((s) => s.currentProject);
const { generationJobs, removeSuggestionFromJob } = useIdeationStore();
const [addingId, setAddingId] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<Set<string>>(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<string, number> = {};
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 (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-3xl w-full mx-auto space-y-4">
{/* Status text */}
{(generatingCount > 0 || allSuggestions.length > 0) && (
<p className="text-sm text-muted-foreground">
{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`}
</p>
)}
{/* Tag Filters */}
{availableTags.length > 0 && (
<TagFilter
tags={availableTags}
tagCounts={tagCounts}
selectedTags={selectedTags}
onToggleTag={handleToggleTag}
/>
)}
{/* Generating/Error Jobs */}
{activeJobs.length > 0 && (
<div className="space-y-3">
{activeJobs.map((job) => (
<GeneratingCard key={job.id} job={job} />
))}
</div>
)}
{/* Suggestions List */}
{filteredSuggestions.length > 0 && (
<div className="space-y-3">
{filteredSuggestions.map(({ suggestion, job }) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
job={job}
onAccept={() => handleAccept(suggestion, job.id)}
onRemove={() => handleRemove(suggestion.id, job.id)}
isAdding={addingId === suggestion.id}
/>
))}
</div>
)}
{/* No results after filtering */}
{filteredSuggestions.length === 0 && allSuggestions.length > 0 && (
<Card>
<CardContent className="py-8">
<div className="text-center text-muted-foreground">
<p>No ideas match the selected filters</p>
<button
onClick={() => setSelectedTags(new Set())}
className="text-primary hover:underline mt-2"
>
Clear filters
</button>
</div>
</CardContent>
</Card>
)}
{/* Empty State */}
{isEmpty && (
<Card>
<CardContent className="py-16">
<div className="text-center">
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No ideas yet</h3>
<p className="text-muted-foreground mb-6">
Generate ideas by selecting a category and prompt type
</p>
<Button onClick={onGenerateIdeas} size="lg" className="gap-2">
<Lightbulb className="w-5 h-5" />
Generate Ideas
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
/**
* PromptCategoryGrid - Grid of prompt categories to select from
*/
import {
ArrowLeft,
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
Loader2,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import type { IdeaCategory } from '@automaker/types';
interface PromptCategoryGridProps {
onSelect: (category: IdeaCategory) => void;
onBack: () => void;
}
const iconMap: Record<string, typeof Zap> = {
Zap,
Palette,
Code,
TrendingUp,
Cpu,
Shield,
Gauge,
Accessibility,
BarChart3,
};
export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps) {
const { categories, isLoading, error } = useGuidedPrompts();
return (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-4xl w-full mx-auto space-y-4">
{/* Back link */}
<button
onClick={onBack}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</button>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading categories...</span>
</div>
)}
{error && (
<div className="text-center py-12 text-destructive">
<p>Failed to load categories: {error}</p>
</div>
)}
{!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map((category) => {
const Icon = iconMap[category.icon] || Zap;
return (
<Card
key={category.id}
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
onClick={() => onSelect(category.id)}
>
<CardContent className="p-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="p-4 rounded-full bg-primary/10">
<Icon className="w-8 h-8 text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg">{category.name}</h3>
<p className="text-muted-foreground text-sm mt-1">{category.description}</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
/**
* 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 { useGuidedPrompts } from '@/hooks/use-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<string | null>(null);
const [startedPrompts, setStartedPrompts] = useState<Set<string>>(new Set());
const navigate = useNavigate();
const {
getPromptsByCategory,
isLoading: isLoadingPrompts,
error: promptsError,
} = useGuidedPrompts();
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 (
<div className="flex-1 flex flex-col p-6 overflow-auto">
<div className="max-w-3xl w-full mx-auto space-y-4">
{/* Back link */}
<button
onClick={onBack}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span>Back</span>
</button>
<div className="space-y-3">
{isLoadingPrompts && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
</div>
)}
{promptsError && (
<div className="text-center py-8 text-destructive">
<p>Failed to load prompts: {promptsError}</p>
</div>
)}
{!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 (
<Card
key={prompt.id}
className={`transition-all ${
isDisabled
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer hover:border-primary hover:shadow-md'
} ${isLoading || isGenerating ? 'border-blue-500 ring-1 ring-blue-500' : ''} ${
isStarted && !isGenerating ? 'border-green-500/50' : ''
}`}
onClick={() => !isDisabled && handleSelectPrompt(prompt)}
>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div
className={`p-2 rounded-lg mt-0.5 ${
isLoading || isGenerating
? 'bg-blue-500/10'
: isStarted
? 'bg-green-500/10'
: 'bg-primary/10'
}`}
>
{isLoading || isGenerating ? (
<Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
) : isStarted ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<Lightbulb className="w-4 h-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold">{prompt.title}</h3>
<p className="text-muted-foreground text-sm mt-1">{prompt.description}</p>
{(isLoading || isGenerating) && (
<p className="text-blue-500 text-sm mt-2">Generating in dashboard...</p>
)}
{isStarted && !isGenerating && (
<p className="text-green-500 text-sm mt-2">
Already generated - check dashboard
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
/**
* 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 { 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';
// Breadcrumb component - compact inline breadcrumbs
function IdeationBreadcrumbs({
currentMode,
selectedCategory,
onNavigate,
}: {
currentMode: IdeationMode;
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)
if (currentMode === 'dashboard') {
return null;
}
return (
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
<button
onClick={() => onNavigate('dashboard')}
className="hover:text-foreground transition-colors"
>
Dashboard
</button>
<ChevronRight className="w-3 h-3" />
{selectedCategory && categoryInfo ? (
<>
<button
onClick={() => onNavigate('prompts', null)}
className="hover:text-foreground transition-colors"
>
Generate Ideas
</button>
<ChevronRight className="w-3 h-3" />
<span className="text-foreground">{categoryInfo.name}</span>
</>
) : (
<span className="text-foreground">Generate Ideas</span>
)}
</nav>
);
}
// 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 { 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 (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
{showBackButton && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="w-5 h-5" />
</Button>
)}
<div>
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">Ideation</h1>
</div>
{currentMode === 'dashboard' ? (
<p className="text-sm text-muted-foreground">{subtitle}</p>
) : (
<IdeationBreadcrumbs
currentMode={currentMode}
selectedCategory={selectedCategory}
onNavigate={onNavigate}
/>
)}
</div>
</div>
<div className="flex gap-2 items-center">
<Button onClick={onGenerateIdeas} className="gap-2">
<Lightbulb className="w-4 h-4" />
Generate Ideas
</Button>
</div>
</div>
);
}
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 (
<div
className="flex-1 flex items-center justify-center content-bg"
data-testid="ideation-view"
>
<div className="text-center text-muted-foreground">
<p>Open a project to start brainstorming ideas</p>
</div>
</div>
);
}
return (
<div
className="flex-1 flex flex-col content-bg min-h-0 overflow-hidden"
data-testid="ideation-view"
>
{/* Header with breadcrumbs - always shown */}
<IdeationHeader
currentMode={currentMode}
selectedCategory={selectedCategory}
onNavigate={handleNavigate}
onGenerateIdeas={handleGenerateIdeas}
onBack={handleBackFromPrompts}
/>
{/* Dashboard - main view */}
{currentMode === 'dashboard' && <IdeationDashboard onGenerateIdeas={handleGenerateIdeas} />}
{/* Prompts - category selection */}
{currentMode === 'prompts' && !selectedCategory && (
<PromptCategoryGrid onSelect={handleSelectCategory} onBack={handleBackFromPrompts} />
)}
{/* Prompts - prompt selection within category */}
{currentMode === 'prompts' && selectedCategory && (
<PromptList category={selectedCategory} onBack={handleBackFromPrompts} />
)}
</div>
);
}

View File

@@ -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';

View File

@@ -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 (
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
<div className="space-y-4">
<div className="flex items-start gap-3">
<Switch
id="mcp-auto-approve"
checked={mcpAutoApproveTools}
onCheckedChange={async (checked) => {
onAutoApproveChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-auto-approve-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
Auto-approve MCP tool calls
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use MCP tools without permission prompts.
</p>
{mcpAutoApproveTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Bypasses normal permission checks
</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Switch
id="mcp-unrestricted"
checked={mcpUnrestrictedTools}
onCheckedChange={async (checked) => {
onUnrestrictedChange(checked);
await syncSettingsToServer();
}}
data-testid="mcp-unrestricted-toggle"
className="mt-0.5"
/>
<div className="space-y-1 flex-1">
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
Unrestricted tool access
</Label>
<p className="text-xs text-muted-foreground">
When enabled, the AI agent can use any tool, not just the default set.
</p>
{mcpUnrestrictedTools && (
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
<ShieldAlert className="h-3 w-3" />
Agent has full tool access including file writes and bash
</p>
)}
</div>
</div>
{hasAnyEnabled && (
<div
className={cn(
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
'text-xs text-amber-700 dark:text-amber-400'
)}
>
<p className="font-medium mb-1">Security Note</p>
<p>
These settings reduce security restrictions for MCP tool usage. Only enable if you
trust all configured MCP servers.
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -24,16 +24,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);
@@ -941,10 +932,6 @@ export function useMCPServers() {
return {
// Store state
mcpServers,
mcpAutoApproveTools,
mcpUnrestrictedTools,
setMcpAutoApproveTools,
setMcpUnrestrictedTools,
// Dialog state
isAddDialogOpen,

View File

@@ -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 && (
<MCPPermissionSettings
mcpAutoApproveTools={mcpAutoApproveTools}
mcpUnrestrictedTools={mcpUnrestrictedTools}
onAutoApproveChange={setMcpAutoApproveTools}
onUnrestrictedChange={setMcpUnrestrictedTools}
/>
)}
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
<div className="p-6">

View File

@@ -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';

View File

@@ -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<void>;
getPromptsByCategory: (category: IdeaCategory) => IdeationPrompt[];
getPromptById: (id: string) => IdeationPrompt | undefined;
getCategoryById: (id: IdeaCategory) => PromptCategory | undefined;
}
export function useGuidedPrompts(): UseGuidedPromptsReturn {
const [prompts, setPrompts] = useState<IdeationPrompt[]>([]);
const [categories, setCategories] = useState<PromptCategory[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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]
);
}

View File

@@ -234,8 +234,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers,
mcpAutoApproveTools: state.mcpAutoApproveTools,
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
promptCustomization: state.promptCustomization,
projects: state.projects,
trashedProjects: state.trashedProjects,
@@ -340,12 +338,10 @@ export async function loadMCPServersFromServer(): Promise<boolean> {
}
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 });
logger.info(`Loaded ${mcpServers.length} MCP servers from server`);
return true;

View File

@@ -39,55 +39,118 @@ export function formatModelName(model: string): string {
return model.split('-').slice(1, 3).join(' ');
}
/**
* Helper to extract a balanced JSON object from a string starting at a given position
*/
function extractJsonObject(str: string, startIdx: number): string | null {
if (str[startIdx] !== '{') return null;
let depth = 0;
let inString = false;
let escapeNext = false;
for (let i = startIdx; i < str.length; i++) {
const char = str[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
if (char === '"' && !escapeNext) {
inString = !inString;
continue;
}
if (inString) continue;
if (char === '{') depth++;
else if (char === '}') {
depth--;
if (depth === 0) {
return str.slice(startIdx, i + 1);
}
}
}
return null;
}
/**
* Extracts todos from the context content
* Looks for TodoWrite tool calls in the format:
* TodoWrite: [{"content": "...", "status": "..."}]
* 🔧 Tool: TodoWrite
* Input: {"todos": [{"content": "...", "status": "..."}]}
*/
function extractTodos(content: string): AgentTaskInfo['todos'] {
const todos: AgentTaskInfo['todos'] = [];
// Look for TodoWrite tool inputs
const todoMatches = content.matchAll(
/TodoWrite.*?(?:"todos"\s*:\s*)?(\[[\s\S]*?\](?=\s*(?:\}|$|🔧|📋|⚡|✅|❌)))/g
);
// Find all occurrences of TodoWrite tool calls
const todoWriteMarker = '🔧 Tool: TodoWrite';
let searchStart = 0;
for (const match of todoMatches) {
try {
// Try to find JSON array in the match
const jsonStr = match[1] || match[0];
const arrayMatch = jsonStr.match(/\[[\s\S]*?\]/);
if (arrayMatch) {
const parsed = JSON.parse(arrayMatch[0]);
if (Array.isArray(parsed)) {
for (const item of parsed) {
while (true) {
const markerIdx = content.indexOf(todoWriteMarker, searchStart);
if (markerIdx === -1) break;
// Look for "Input:" after the marker
const inputIdx = content.indexOf('Input:', markerIdx);
if (inputIdx === -1 || inputIdx > markerIdx + 100) {
searchStart = markerIdx + 1;
continue;
}
// Find the start of the JSON object
const jsonStart = content.indexOf('{', inputIdx);
if (jsonStart === -1) {
searchStart = markerIdx + 1;
continue;
}
// Extract the complete JSON object
const jsonStr = extractJsonObject(content, jsonStart);
if (jsonStr) {
try {
const parsed = JSON.parse(jsonStr) as {
todos?: Array<{ content: string; status: string }>;
};
if (parsed.todos && Array.isArray(parsed.todos)) {
// Clear previous todos - we want the latest state
todos.length = 0;
for (const item of parsed.todos) {
if (item.content && item.status) {
// Check if this todo already exists (avoid duplicates)
if (!todos.some((t) => t.content === item.content)) {
todos.push({
content: item.content,
status: item.status,
});
}
todos.push({
content: item.content,
status: item.status as 'pending' | 'in_progress' | 'completed',
});
}
}
}
} catch {
// Ignore parse errors
}
} catch {
// Ignore parse errors
}
searchStart = markerIdx + 1;
}
// Also try to extract from markdown task lists
const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
for (const match of markdownTodos) {
const isCompleted = match[1].toLowerCase() === 'x';
const content = match[2].trim();
if (!todos.some((t) => t.content === content)) {
todos.push({
content,
status: isCompleted ? 'completed' : 'pending',
});
// Also try to extract from markdown task lists as fallback
if (todos.length === 0) {
const markdownTodos = content.matchAll(/- \[([ xX])\] (.+)/g);
for (const match of markdownTodos) {
const isCompleted = match[1].toLowerCase() === 'x';
const todoContent = match[2].trim();
if (!todos.some((t) => t.content === todoContent)) {
todos.push({
content: todoContent,
status: isCompleted ? 'completed' : 'pending',
});
}
}
}

View File

@@ -153,3 +153,37 @@ export async function apiDeleteRaw(
): Promise<Response> {
return apiFetch(endpoint, 'DELETE', options);
}
/**
* Build an authenticated image URL for use in <img> 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()}`;
}

View File

@@ -1,5 +1,4 @@
// Type definitions for Electron IPC API
import { createLogger } from '@automaker/utils/logger';
import type { SessionListItem, Message } from '@/types/electron';
import type { ClaudeUsageResponse } from '@/store/app-store';
import type {
@@ -11,10 +10,21 @@ import type {
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
ModelAlias,
AgentModel,
GitHubComment,
IssueCommentsResult,
ThinkingLevel,
Idea,
IdeaCategory,
IdeationSession,
IdeationMessage,
IdeationPrompt,
PromptCategory,
ProjectAnalysisResult,
AnalysisSuggestion,
StartSessionOptions,
CreateIdeaInput,
UpdateIdeaInput,
ConvertToFeatureOptions,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -32,6 +42,104 @@ export type {
IssueCommentsResult,
};
// Re-export ideation types
export type {
Idea,
IdeaCategory,
IdeationSession,
IdeationMessage,
IdeationPrompt,
PromptCategory,
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 }>;
// 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;
}
export interface FileEntry {
name: string;
isDirectory: boolean;
@@ -96,8 +204,6 @@ import type {
ProviderStatus,
} from '@/types/electron';
const logger = createLogger('Electron');
// Import HTTP API client (ES module)
import { getHttpApiClient, getServerUrlSync } from './http-api-client';
@@ -208,8 +314,7 @@ export interface GitHubAPI {
validateIssue: (
projectPath: string,
issue: IssueValidationInput,
model?: ModelAlias,
thinkingLevel?: string
model?: AgentModel
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
/** Check validation status for an issue or all issues */
getValidationStatus: (
@@ -283,9 +388,7 @@ export type SuggestionType = 'features' | 'refactoring' | 'security' | 'performa
export interface SuggestionsAPI {
generate: (
projectPath: string,
suggestionType?: SuggestionType,
model?: string,
thinkingLevel?: ThinkingLevel
suggestionType?: SuggestionType
) => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{
@@ -565,10 +668,7 @@ export interface ElectronAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (
authMethod?: 'cli' | 'api_key',
apiKey?: string
) => Promise<{
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
@@ -582,19 +682,6 @@ export interface ElectronAPI {
user: string | null;
error?: string;
}>;
getCursorStatus?: () => Promise<{
success: boolean;
installed?: boolean;
version?: string | null;
path?: string | null;
auth?: {
authenticated: boolean;
method: string;
};
installCommand?: string;
loginCommand?: string;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
};
@@ -612,8 +699,7 @@ export interface ElectronAPI {
message: string,
workingDirectory?: string,
imagePaths?: string[],
model?: string,
thinkingLevel?: string
model?: string
) => Promise<{ success: boolean; error?: string }>;
getHistory: (sessionId: string) => Promise<{
success: boolean;
@@ -671,6 +757,7 @@ export interface ElectronAPI {
error?: string;
}>;
};
ideation?: IdeationAPI;
}
// Note: Window interface is declared in @/types/electron.d.ts
@@ -781,8 +868,8 @@ export const getCurrentApiMode = (): 'http' => {
// Debug helpers
if (typeof window !== 'undefined') {
(window as any).__checkApiMode = () => {
logger.info('Current API mode:', getCurrentApiMode());
logger.info('isElectron():', isElectron());
console.log('Current API mode:', getCurrentApiMode());
console.log('isElectron():', isElectron());
};
}
@@ -1023,7 +1110,7 @@ const getMockElectronAPI = (): ElectronAPI => {
// Store the image data in mock file system for testing
mockFileSystem[tempFilePath] = data;
logger.info('Mock saved image to temp:', tempFilePath);
console.log('[Mock] Saved image to temp:', tempFilePath);
return { success: true, path: tempFilePath };
},
@@ -1068,7 +1155,7 @@ const getMockElectronAPI = (): ElectronAPI => {
// Mock Claude API
claude: {
getUsage: async () => {
logger.info('Mock getting Claude usage');
console.log('[Mock] Getting Claude usage');
return {
sessionTokensUsed: 0,
sessionLimit: 0,
@@ -1150,10 +1237,7 @@ interface SetupAPI {
isMac: boolean;
isLinux: boolean;
}>;
verifyClaudeAuth: (
authMethod?: 'cli' | 'api_key',
apiKey?: string
) => Promise<{
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
@@ -1175,7 +1259,7 @@ interface SetupAPI {
function createMockSetupAPI(): SetupAPI {
return {
getClaudeStatus: async () => {
logger.info('Mock Getting Claude status');
console.log('[Mock] Getting Claude status');
return {
success: true,
status: 'not_installed',
@@ -1192,7 +1276,7 @@ function createMockSetupAPI(): SetupAPI {
},
installClaude: async () => {
logger.info('Mock Installing Claude CLI');
console.log('[Mock] Installing Claude CLI');
// Simulate installation delay
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
@@ -1203,7 +1287,7 @@ function createMockSetupAPI(): SetupAPI {
},
authClaude: async () => {
logger.info('Mock Auth Claude CLI');
console.log('[Mock] Auth Claude CLI');
return {
success: true,
requiresManualAuth: true,
@@ -1212,13 +1296,13 @@ function createMockSetupAPI(): SetupAPI {
},
storeApiKey: async (provider: string, apiKey: string) => {
logger.info('Mock Storing API key for:', provider);
console.log('[Mock] Storing API key for:', provider);
// In mock mode, we just pretend to store it (it's already in the app store)
return { success: true };
},
getApiKeys: async () => {
logger.info('Mock Getting API keys');
console.log('[Mock] Getting API keys');
return {
success: true,
hasAnthropicKey: false,
@@ -1227,7 +1311,7 @@ function createMockSetupAPI(): SetupAPI {
},
deleteApiKey: async (provider: string) => {
logger.info('Mock Deleting API key for:', provider);
console.log('[Mock] Deleting API key for:', provider);
return { success: true, message: `API key for ${provider} deleted` };
},
@@ -1243,12 +1327,8 @@ function createMockSetupAPI(): SetupAPI {
};
},
verifyClaudeAuth: async (authMethod?: 'cli' | 'api_key', apiKey?: string) => {
logger.info(
'Mock verifying Claude auth with method:',
authMethod,
apiKey ? '(with key)' : ''
);
verifyClaudeAuth: async (authMethod?: 'cli' | 'api_key') => {
console.log('[Mock] Verifying Claude auth with method:', authMethod);
// Mock always returns not authenticated
return {
success: true,
@@ -1258,7 +1338,7 @@ function createMockSetupAPI(): SetupAPI {
},
getGhStatus: async () => {
logger.info('Mock Getting GitHub CLI status');
console.log('[Mock] Getting GitHub CLI status');
return {
success: true,
installed: false,
@@ -1285,7 +1365,7 @@ function createMockSetupAPI(): SetupAPI {
function createMockWorktreeAPI(): WorktreeAPI {
return {
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
logger.info('Mock Merging feature:', {
console.log('[Mock] Merging feature:', {
projectPath,
featureId,
options,
@@ -1294,7 +1374,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getInfo: async (projectPath: string, featureId: string) => {
logger.info('Mock Getting worktree info:', { projectPath, featureId });
console.log('[Mock] Getting worktree info:', { projectPath, featureId });
return {
success: true,
worktreePath: `/mock/worktrees/${featureId}`,
@@ -1304,7 +1384,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getStatus: async (projectPath: string, featureId: string) => {
logger.info('Mock Getting worktree status:', {
console.log('[Mock] Getting worktree status:', {
projectPath,
featureId,
});
@@ -1318,12 +1398,12 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
list: async (projectPath: string) => {
logger.info('Mock Listing worktrees:', { projectPath });
console.log('[Mock] Listing worktrees:', { projectPath });
return { success: true, worktrees: [] };
},
listAll: async (projectPath: string, includeDetails?: boolean) => {
logger.info('Mock Listing all worktrees:', {
console.log('[Mock] Listing all worktrees:', {
projectPath,
includeDetails,
});
@@ -1344,7 +1424,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
create: async (projectPath: string, branchName: string, baseBranch?: string) => {
logger.info('Mock Creating worktree:', {
console.log('[Mock] Creating worktree:', {
projectPath,
branchName,
baseBranch,
@@ -1360,7 +1440,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
delete: async (projectPath: string, worktreePath: string, deleteBranch?: boolean) => {
logger.info('Mock Deleting worktree:', {
console.log('[Mock] Deleting worktree:', {
projectPath,
worktreePath,
deleteBranch,
@@ -1375,7 +1455,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
commit: async (worktreePath: string, message: string) => {
logger.info('Mock Committing changes:', { worktreePath, message });
console.log('[Mock] Committing changes:', { worktreePath, message });
return {
success: true,
result: {
@@ -1388,7 +1468,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
push: async (worktreePath: string, force?: boolean) => {
logger.info('Mock Pushing worktree:', { worktreePath, force });
console.log('[Mock] Pushing worktree:', { worktreePath, force });
return {
success: true,
result: {
@@ -1400,7 +1480,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
createPR: async (worktreePath: string, options?: any) => {
logger.info('Mock Creating PR:', { worktreePath, options });
console.log('[Mock] Creating PR:', { worktreePath, options });
return {
success: true,
result: {
@@ -1415,7 +1495,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getDiffs: async (projectPath: string, featureId: string) => {
logger.info('Mock Getting file diffs:', { projectPath, featureId });
console.log('[Mock] Getting file diffs:', { projectPath, featureId });
return {
success: true,
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
@@ -1428,7 +1508,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getFileDiff: async (projectPath: string, featureId: string, filePath: string) => {
logger.info('Mock Getting file diff:', {
console.log('[Mock] Getting file diff:', {
projectPath,
featureId,
filePath,
@@ -1441,7 +1521,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
pull: async (worktreePath: string) => {
logger.info('Mock Pulling latest changes for:', worktreePath);
console.log('[Mock] Pulling latest changes for:', worktreePath);
return {
success: true,
result: {
@@ -1453,7 +1533,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
checkoutBranch: async (worktreePath: string, branchName: string) => {
logger.info('Mock Creating and checking out branch:', {
console.log('[Mock] Creating and checking out branch:', {
worktreePath,
branchName,
});
@@ -1468,7 +1548,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
listBranches: async (worktreePath: string) => {
logger.info('Mock Listing branches for:', worktreePath);
console.log('[Mock] Listing branches for:', worktreePath);
return {
success: true,
result: {
@@ -1485,7 +1565,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
switchBranch: async (worktreePath: string, branchName: string) => {
logger.info('Mock Switching to branch:', { worktreePath, branchName });
console.log('[Mock] Switching to branch:', { worktreePath, branchName });
return {
success: true,
result: {
@@ -1497,7 +1577,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
openInEditor: async (worktreePath: string) => {
logger.info('Mock Opening in editor:', worktreePath);
console.log('[Mock] Opening in editor:', worktreePath);
return {
success: true,
result: {
@@ -1508,7 +1588,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getDefaultEditor: async () => {
logger.info('Mock Getting default editor');
console.log('[Mock] Getting default editor');
return {
success: true,
result: {
@@ -1519,7 +1599,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
initGit: async (projectPath: string) => {
logger.info('Mock Initializing git:', projectPath);
console.log('[Mock] Initializing git:', projectPath);
return {
success: true,
result: {
@@ -1530,7 +1610,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
startDevServer: async (projectPath: string, worktreePath: string) => {
logger.info('Mock Starting dev server:', { projectPath, worktreePath });
console.log('[Mock] Starting dev server:', { projectPath, worktreePath });
return {
success: true,
result: {
@@ -1543,7 +1623,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
stopDevServer: async (worktreePath: string) => {
logger.info('Mock Stopping dev server:', worktreePath);
console.log('[Mock] Stopping dev server:', worktreePath);
return {
success: true,
result: {
@@ -1554,7 +1634,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
listDevServers: async () => {
logger.info('Mock Listing dev servers');
console.log('[Mock] Listing dev servers');
return {
success: true,
result: {
@@ -1564,7 +1644,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
getPRInfo: async (worktreePath: string, branchName: string) => {
logger.info('Mock Getting PR info:', { worktreePath, branchName });
console.log('[Mock] Getting PR info:', { worktreePath, branchName });
return {
success: true,
result: {
@@ -1580,7 +1660,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
function createMockGitAPI(): GitAPI {
return {
getDiffs: async (projectPath: string) => {
logger.info('Mock Getting git diffs for project:', { projectPath });
console.log('[Mock] Getting git diffs for project:', { projectPath });
return {
success: true,
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
@@ -1593,7 +1673,7 @@ function createMockGitAPI(): GitAPI {
},
getFileDiff: async (projectPath: string, filePath: string) => {
logger.info('Mock Getting git file diff:', { projectPath, filePath });
console.log('[Mock] Getting git file diff:', { projectPath, filePath });
return {
success: true,
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
@@ -1617,7 +1697,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
}
mockAutoModeRunning = true;
logger.info(`Mock auto mode started with maxConcurrency: ${maxConcurrency || 3}`);
console.log(`[Mock] Auto mode started with maxConcurrency: ${maxConcurrency || 3}`);
const featureId = 'auto-mode-0';
mockRunningFeatures.add(featureId);
@@ -1686,8 +1766,8 @@ function createMockAutoModeAPI(): AutoModeAPI {
};
}
logger.info(
`Mock running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
console.log(
`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}, worktreePath: ${worktreePath}`
);
mockRunningFeatures.add(featureId);
simulateAutoModeLoop(projectPath, featureId);
@@ -1854,7 +1934,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
};
}
logger.info('Mock Follow-up feature:', {
console.log('[Mock] Follow-up feature:', {
featureId,
prompt,
imagePaths,
@@ -1871,7 +1951,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
},
commitFeature: async (projectPath: string, featureId: string, worktreePath?: string) => {
logger.info('Mock Committing feature:', {
console.log('[Mock] Committing feature:', {
projectPath,
featureId,
worktreePath,
@@ -1916,7 +1996,7 @@ function createMockAutoModeAPI(): AutoModeAPI {
editedPlan?: string,
feedback?: string
) => {
logger.info('Mock Plan approval:', {
console.log('[Mock] Plan approval:', {
projectPath,
featureId,
approved,
@@ -2068,12 +2148,7 @@ let mockSuggestionsTimeout: NodeJS.Timeout | null = null;
function createMockSuggestionsAPI(): SuggestionsAPI {
return {
generate: async (
projectPath: string,
suggestionType: SuggestionType = 'features',
model?: string,
thinkingLevel?: ThinkingLevel
) => {
generate: async (projectPath: string, suggestionType: SuggestionType = 'features') => {
if (mockSuggestionsRunning) {
return {
success: false,
@@ -2082,11 +2157,7 @@ function createMockSuggestionsAPI(): SuggestionsAPI {
}
mockSuggestionsRunning = true;
logger.info(
`Mock generating ${suggestionType} suggestions for: ${projectPath}` +
(model ? ` with model: ${model}` : '') +
(thinkingLevel ? ` thinkingLevel: ${thinkingLevel}` : '')
);
console.log(`[Mock] Generating ${suggestionType} suggestions for: ${projectPath}`);
// Simulate async suggestion generation
simulateSuggestionsGeneration(suggestionType);
@@ -2310,8 +2381,8 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
}
mockSpecRegenerationRunning = true;
logger.info(
`Mock creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
console.log(
`[Mock] Creating initial spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec creation
@@ -2335,8 +2406,8 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
}
mockSpecRegenerationRunning = true;
logger.info(
`Mock regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
console.log(
`[Mock] Regenerating spec for: ${projectPath}, generateFeatures: ${generateFeatures}, maxFeatures: ${maxFeatures}`
);
// Simulate async spec regeneration
@@ -2354,8 +2425,8 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
}
mockSpecRegenerationRunning = true;
logger.info(
`Mock generating features from existing spec for: ${projectPath}, maxFeatures: ${maxFeatures}`
console.log(
`[Mock] Generating features from existing spec for: ${projectPath}, maxFeatures: ${maxFeatures}`
);
// Simulate async feature generation
@@ -2620,7 +2691,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
// Store features in mock file system using features/{id}/feature.json pattern
return {
getAll: async (projectPath: string) => {
logger.info('Mock Getting all features for:', projectPath);
console.log('[Mock] Getting all features for:', projectPath);
// Check if test has set mock features via global variable
const testFeatures = (window as any).__mockFeatures;
@@ -2645,7 +2716,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
features.push(feature);
}
} catch (error) {
logger.error('Mock Failed to parse feature:', error);
console.error('[Mock] Failed to parse feature:', error);
}
}
@@ -2658,7 +2729,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
},
get: async (projectPath: string, featureId: string) => {
logger.info('Mock Getting feature:', { projectPath, featureId });
console.log('[Mock] Getting feature:', { projectPath, featureId });
const featurePath = `${projectPath}/.automaker/features/${featureId}/feature.json`;
const content = mockFileSystem[featurePath];
if (content) {
@@ -2668,7 +2739,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
},
create: async (projectPath: string, feature: Feature) => {
logger.info('Mock Creating feature:', {
console.log('[Mock] Creating feature:', {
projectPath,
featureId: feature.id,
});
@@ -2678,7 +2749,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
},
update: async (projectPath: string, featureId: string, updates: Partial<Feature>) => {
logger.info('Mock Updating feature:', {
console.log('[Mock] Updating feature:', {
projectPath,
featureId,
updates,
@@ -2694,7 +2765,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
},
delete: async (projectPath: string, featureId: string) => {
logger.info('Mock Deleting feature:', { projectPath, featureId });
console.log('[Mock] Deleting feature:', { projectPath, featureId });
const featurePath = `${projectPath}/.automaker/features/${featureId}/feature.json`;
delete mockFileSystem[featurePath];
// Also delete agent-output.md if it exists
@@ -2704,14 +2775,14 @@ function createMockFeaturesAPI(): FeaturesAPI {
},
getAgentOutput: async (projectPath: string, featureId: string) => {
logger.info('Mock Getting agent output:', { projectPath, featureId });
console.log('[Mock] Getting agent output:', { projectPath, featureId });
const agentOutputPath = `${projectPath}/.automaker/features/${featureId}/agent-output.md`;
const content = mockFileSystem[agentOutputPath];
return { success: true, content: content || null };
},
generateTitle: async (description: string) => {
logger.info('Mock Generating title for:', description.substring(0, 50));
console.log('[Mock] Generating title for:', description.substring(0, 50));
// Mock title generation - just take first few words
const words = description.split(/\s+/).slice(0, 6).join(' ');
const title = words.length > 40 ? words.substring(0, 40) + '...' : words;
@@ -2724,7 +2795,7 @@ function createMockFeaturesAPI(): FeaturesAPI {
function createMockRunningAgentsAPI(): RunningAgentsAPI {
return {
getAll: async () => {
logger.info('Mock Getting all running agents');
console.log('[Mock] Getting all running agents');
// Return running agents from mock auto mode state
const runningAgents: RunningAgent[] = Array.from(mockRunningFeatures).map((featureId) => ({
featureId,
@@ -2749,7 +2820,7 @@ let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
function createMockGitHubAPI(): GitHubAPI {
return {
checkRemote: async (projectPath: string) => {
logger.info('Mock Checking GitHub remote for:', projectPath);
console.log('[Mock] Checking GitHub remote for:', projectPath);
return {
success: true,
hasGitHubRemote: false,
@@ -2759,7 +2830,7 @@ function createMockGitHubAPI(): GitHubAPI {
};
},
listIssues: async (projectPath: string) => {
logger.info('Mock Listing GitHub issues for:', projectPath);
console.log('[Mock] Listing GitHub issues for:', projectPath);
return {
success: true,
openIssues: [],
@@ -2767,15 +2838,15 @@ function createMockGitHubAPI(): GitHubAPI {
};
},
listPRs: async (projectPath: string) => {
logger.info('Mock Listing GitHub PRs for:', projectPath);
console.log('[Mock] Listing GitHub PRs for:', projectPath);
return {
success: true,
openPRs: [],
mergedPRs: [],
};
},
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: ModelAlias) => {
logger.info('Mock Starting async validation:', { projectPath, issue, model });
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
// Simulate async validation in background
setTimeout(() => {
@@ -2816,7 +2887,7 @@ function createMockGitHubAPI(): GitHubAPI {
};
},
getValidationStatus: async (projectPath: string, issueNumber?: number) => {
logger.info('Mock Getting validation status:', { projectPath, issueNumber });
console.log('[Mock] Getting validation status:', { projectPath, issueNumber });
return {
success: true,
isRunning: false,
@@ -2824,21 +2895,21 @@ function createMockGitHubAPI(): GitHubAPI {
};
},
stopValidation: async (projectPath: string, issueNumber: number) => {
logger.info('Mock Stopping validation:', { projectPath, issueNumber });
console.log('[Mock] Stopping validation:', { projectPath, issueNumber });
return {
success: true,
message: `Validation for issue #${issueNumber} stopped`,
};
},
getValidations: async (projectPath: string, issueNumber?: number) => {
logger.info('Mock Getting validations:', { projectPath, issueNumber });
console.log('[Mock] Getting validations:', { projectPath, issueNumber });
return {
success: true,
validations: [],
};
},
markValidationViewed: async (projectPath: string, issueNumber: number) => {
logger.info('Mock Marking validation as viewed:', { projectPath, issueNumber });
console.log('[Mock] Marking validation as viewed:', { projectPath, issueNumber });
return {
success: true,
};
@@ -2850,7 +2921,7 @@ function createMockGitHubAPI(): GitHubAPI {
};
},
getIssueComments: async (projectPath: string, issueNumber: number, cursor?: string) => {
logger.info('Mock Getting issue comments:', { projectPath, issueNumber, cursor });
console.log('[Mock] Getting issue comments:', { projectPath, issueNumber, cursor });
return {
success: true,
comments: [],

View File

@@ -27,6 +27,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';
@@ -371,7 +378,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;
@@ -1577,8 +1586,6 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record<string, string>;
enabled?: boolean;
}>;
mcpAutoApproveTools?: boolean;
mcpUnrestrictedTools?: boolean;
};
error?: string;
}> => this.get('/api/settings/global'),
@@ -1779,6 +1786,66 @@ 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: (
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);
},
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.

View File

@@ -1196,6 +1196,53 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
return merged;
}
/**
* Extracts summary content from raw log output
* Returns the summary text if found, or null if no summary exists
*/
export function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) {
return null;
}
// Try to find <summary> tags first (preferred format)
const summaryTagMatch = rawOutput.match(/<summary>([\s\S]*?)<\/summary>/);
if (summaryTagMatch) {
return summaryTagMatch[1].trim();
}
// Try to find markdown ## Summary section
const summaryHeaderMatch = rawOutput.match(/^##\s+Summary\s*\n([\s\S]*?)(?=\n##\s+|$)/m);
if (summaryHeaderMatch) {
return summaryHeaderMatch[1].trim();
}
// Try other summary formats (Feature, Changes, Implementation)
const otherHeaderMatch = rawOutput.match(
/^##\s+(Feature|Changes|Implementation)\s*\n([\s\S]*?)(?=\n##\s+|$)/m
);
if (otherHeaderMatch) {
return `## ${otherHeaderMatch[1]}\n${otherHeaderMatch[2].trim()}`;
}
// Try to find summary introduction lines
const introMatch = rawOutput.match(
/(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
);
if (introMatch) {
return introMatch[2].trim();
}
const completionMatch = rawOutput.match(
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/
);
if (completionMatch) {
return completionMatch[2].trim();
}
return null;
}
/**
* Gets the color classes for a log entry type
*/

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { IdeationView } from '@/components/views/ideation-view';
export const Route = createFileRoute('/ideation')({
component: IdeationView,
});

View File

@@ -34,7 +34,8 @@ export type ViewMode =
| 'profiles'
| 'running-agents'
| 'terminal'
| 'wiki';
| 'wiki'
| 'ideation';
export type ThemeMode =
| 'light'
@@ -159,6 +160,9 @@ export interface KeyboardShortcuts {
settings: string;
profiles: string;
terminal: string;
ideation: string;
githubIssues: string;
githubPrs: string;
// UI shortcuts
toggleSidebar: string;
@@ -191,6 +195,9 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
settings: 'S',
profiles: 'M',
terminal: 'T',
ideation: 'I',
githubIssues: 'G',
githubPrs: 'R',
// UI
toggleSidebar: '`',
@@ -504,8 +511,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
@@ -801,8 +806,6 @@ export interface AppActions {
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -1019,8 +1022,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,
@@ -1719,19 +1720,6 @@ export const useAppStore = create<AppState & AppActions>()(
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 });
@@ -3023,8 +3011,6 @@ export const useAppStore = create<AppState & AppActions>()(
skipSandboxWarning: state.skipSandboxWarning,
// MCP settings
mcpServers: state.mcpServers,
mcpAutoApproveTools: state.mcpAutoApproveTools,
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
// Prompt customization
promptCustomization: state.promptCustomization,
// Profiles and sessions

View File

@@ -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<Idea>) => 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<IdeationState & IdeationActions>()(
persist(
(set, get) => ({
...initialState,
// Ideas
setIdeas: (ideas) => set({ ideas }),
addIdea: (idea) =>
set((state) => ({
ideas: [idea, ...state.ideas],
})),
updateIdea: (id, updates) =>
set((state) => ({
ideas: state.ideas.map((idea) => (idea.id === id ? { ...idea, ...updates } : idea)),
})),
removeIdea: (id) =>
set((state) => ({
ideas: state.ideas.filter((idea) => idea.id !== id),
selectedIdeaId: state.selectedIdeaId === id ? null : state.selectedIdeaId,
})),
setSelectedIdea: (id) => set({ selectedIdeaId: id }),
getSelectedIdea: () => {
const state = get();
return state.ideas.find((idea) => idea.id === state.selectedIdeaId) || null;
},
// Generation Jobs
addGenerationJob: (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,
}),
}
)
);

View File

@@ -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 {
@@ -118,21 +119,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

222
dev.mjs Normal file
View File

@@ -0,0 +1,222 @@
#!/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,
docker: 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, 2, or 3): ');
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) {
await 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 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, 2, or 3.', 'red');
}
}
}
// Run main function
main().catch(async (err) => {
console.error(err);
const cleanup = createCleanupHandler(processes);
try {
await cleanup();
} catch (cleanupErr) {
console.error('Cleanup error:', cleanupErr);
}
process.exit(1);
});

649
init.mjs
View File

@@ -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);
});

View File

@@ -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

View File

@@ -188,6 +188,140 @@ export async function ensureAutomakerDir(projectPath: string): Promise<string> {
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<string> {
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'))
// ============================================================================

View File

@@ -26,6 +26,19 @@ 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'
| 'ideation:idea-created'
| 'ideation:idea-updated'
| 'ideation:idea-deleted'
| 'ideation:idea-converted';
export type EventCallback = (type: EventType, payload: unknown) => void;

230
libs/types/src/ideation.ts Normal file
View File

@@ -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;
}

View File

@@ -167,3 +167,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';

View File

@@ -73,8 +73,6 @@ export interface ExecuteOptions {
maxTurns?: number;
allowedTools?: string[];
mcpServers?: Record<string, McpServerConfig>;
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

View File

@@ -482,10 +482,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 */
@@ -679,10 +675,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) */

104
package-lock.json generated
View File

@@ -28,7 +28,7 @@
},
"apps/server": {
"name": "@automaker/server",
"version": "0.7.2",
"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.2",
"version": "0.7.3",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
@@ -675,7 +675,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1259,7 +1258,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1302,7 +1300,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2123,6 +2120,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -2144,6 +2142,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -2160,6 +2159,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -2174,6 +2174,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2941,6 +2942,7 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3065,6 +3067,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3081,6 +3084,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3097,6 +3101,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -3205,6 +3210,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3227,6 +3233,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3249,6 +3256,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3334,6 +3342,7 @@
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
@@ -3356,6 +3365,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3375,6 +3385,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3774,7 +3785,8 @@
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10",
@@ -3788,6 +3800,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3804,6 +3817,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3820,6 +3834,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3836,6 +3851,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3852,6 +3868,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3868,6 +3885,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3884,6 +3902,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3900,6 +3919,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3990,7 +4010,6 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.57.0"
},
@@ -5431,6 +5450,7 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -5764,7 +5784,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -6191,7 +6210,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -6334,7 +6352,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -6345,7 +6362,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6451,7 +6467,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -6945,8 +6960,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
@@ -7044,7 +7058,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7105,7 +7118,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7704,7 +7716,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8236,7 +8247,8 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/cliui": {
"version": "8.0.1",
@@ -8541,7 +8553,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8638,7 +8651,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8940,7 +8952,6 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -9267,6 +9278,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -9287,6 +9299,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9537,7 +9550,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9852,7 +9864,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -11520,6 +11531,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11541,6 +11553,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11562,6 +11575,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11583,6 +11597,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11604,6 +11619,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11625,6 +11641,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11646,6 +11663,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11667,6 +11685,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11688,6 +11707,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11709,6 +11729,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11730,6 +11751,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -14017,6 +14039,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -14033,6 +14056,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -14050,6 +14074,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -14238,7 +14263,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -14248,7 +14272,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14607,6 +14630,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -14795,7 +14819,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14844,6 +14867,7 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -14894,6 +14918,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14916,6 +14941,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14938,6 +14964,7 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14954,6 +14981,7 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14970,6 +14998,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14986,6 +15015,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15002,6 +15032,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15018,6 +15049,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15034,6 +15066,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -15050,6 +15083,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15072,6 +15106,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15094,6 +15129,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15116,6 +15152,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15138,6 +15175,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15160,6 +15198,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15628,6 +15667,7 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
@@ -15797,6 +15837,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15860,6 +15901,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -15957,7 +15999,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16162,7 +16203,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16534,7 +16574,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16624,8 +16663,7 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -16651,7 +16689,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16694,7 +16731,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -16952,7 +16988,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
@@ -17021,7 +17056,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -12,7 +12,8 @@
"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",
"_dev:electron:debug": "npm run dev:electron:debug --workspace=apps/ui",

664
scripts/launcher-utils.mjs Normal file
View File

@@ -0,0 +1,664 @@
/**
* 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<void>}
*/
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<boolean>} - 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<void>}
*/
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<void>}
*/
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<boolean>} - 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<string>} - 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<number>}
*/
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(' 3) Docker Container (Isolated)');
console.log('═══════════════════════════════════════════════════════');
console.log('');
}
// =============================================================================
// Process Cleanup
// =============================================================================
/**
* Create a cleanup handler for spawned processes
* @param {object} processes - Object with process references {server, web, electron, docker}
* @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));
}
if (processes.docker && !processes.docker.killed && processes.docker.pid) {
killPromises.push(killProcessTree(processes.docker.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<ChildProcess>} - 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');
// 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;
}
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}`));
});
});
}
}

281
start.mjs Executable file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* Automaker - Production Mode Launch Script
*
* This script runs the application in production mode (no Vite dev server).
* It builds everything if needed, then serves static files via vite preview.
*
* 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 path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import {
createRestrictedFs,
log,
runNpm,
runNpmAndWait,
runNpx,
printHeader,
printModeMenu,
resolvePortConfiguration,
createCleanupHandler,
setupSignalHandlers,
startServerAndWait,
ensureDependencies,
prompt,
killProcessTree,
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);
// Create restricted fs for this script's directory
const fs = createRestrictedFs(__dirname, 'start.mjs');
// Track background processes for cleanup
const processes = {
server: null,
web: null,
electron: null,
docker: null,
};
/**
* Build all production artifacts
*/
async function ensureProductionBuilds() {
// Always build shared packages first to ensure they're up to date
log('Building shared packages...', 'blue');
try {
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' }, __dirname);
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' },
path.join(__dirname, 'apps', 'server')
);
log('✓ Server built', 'green');
} catch (error) {
log(`Failed to build server: ${error.message}`, 'red');
process.exit(1);
}
// 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);
}
}
/**
* Main function
*/
async function main() {
// Change to script directory
process.chdir(__dirname);
printHeader('Automaker Production Mode');
// Ensure dependencies are installed
await ensureDependencies(fs, __dirname);
// Build production artifacts if needed
await ensureProductionBuilds();
// 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, 2, or 3): ');
if (choice === '1') {
console.log('');
log('Launching Web Application (Production Mode)...', 'blue');
// 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'),
fs,
baseDir: __dirname,
});
if (!processes.server) {
await cleanup();
process.exit(1);
}
log(`Starting web server...`, 'blue');
// 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) => {
processes.web.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');
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 for vite preview to start
await sleep(2000);
// 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) => {
processes.electron.on('close', () => {
// Also kill vite preview when electron closes
if (processes.web && !processes.web.killed && processes.web.pid) {
killProcessTree(processes.web.pid);
}
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, 2, or 3.', 'red');
}
}
}
// Run main function
main().catch(async (err) => {
console.error(err);
const cleanup = createCleanupHandler(processes);
try {
await cleanup();
} catch (cleanupErr) {
console.error('Cleanup error:', cleanupErr);
}
process.exit(1);
});