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

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