mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
12
apps/server/src/routes/ideation/common.ts
Normal file
12
apps/server/src/routes/ideation/common.ts
Normal 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);
|
||||
109
apps/server/src/routes/ideation/index.ts
Normal file
109
apps/server/src/routes/ideation/index.ts
Normal 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;
|
||||
}
|
||||
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal file
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal file
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
77
apps/server/src/routes/ideation/routes/convert.ts
Normal file
77
apps/server/src/routes/ideation/routes/convert.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal file
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal file
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal file
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal file
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal file
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal file
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal file
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal file
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal file
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal file
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1722
apps/server/src/services/ideation-service.ts
Normal file
1722
apps/server/src/services/ideation-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,8 @@ describe('claude-provider.ts', () => {
|
||||
maxTurns: 10,
|
||||
cwd: '/test/dir',
|
||||
allowedTools: ['Read', 'Write'],
|
||||
permissionMode: 'default',
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
788
apps/server/tests/unit/services/ideation-service.test.ts
Normal file
788
apps/server/tests/unit/services/ideation-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
211
apps/ui/src/components/views/ideation-view/index.tsx
Normal file
211
apps/ui/src/components/views/ideation-view/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
86
apps/ui/src/hooks/use-guided-prompts.ts
Normal file
86
apps/ui/src/hooks/use-guided-prompts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
6
apps/ui/src/routes/ideation.tsx
Normal file
6
apps/ui/src/routes/ideation.tsx
Normal 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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
324
apps/ui/src/store/ideation-store.ts
Normal file
324
apps/ui/src/store/ideation-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user