diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index bd0fd4a0..6b63ffa8 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -48,6 +48,7 @@ import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; +import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; // Load environment variables @@ -160,6 +161,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); +app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index 0ddf8741..4e27c2ec 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -12,6 +12,10 @@ import { createHistoryHandler } from './routes/history.js'; import { createStopHandler } from './routes/stop.js'; import { createClearHandler } from './routes/clear.js'; import { createModelHandler } from './routes/model.js'; +import { createQueueAddHandler } from './routes/queue-add.js'; +import { createQueueListHandler } from './routes/queue-list.js'; +import { createQueueRemoveHandler } from './routes/queue-remove.js'; +import { createQueueClearHandler } from './routes/queue-clear.js'; export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router { const router = Router(); @@ -27,5 +31,15 @@ export function createAgentRoutes(agentService: AgentService, _events: EventEmit router.post('/clear', createClearHandler(agentService)); router.post('/model', createModelHandler(agentService)); + // Queue routes + router.post( + '/queue/add', + validatePathParams('imagePaths[]'), + createQueueAddHandler(agentService) + ); + router.post('/queue/list', createQueueListHandler(agentService)); + router.post('/queue/remove', createQueueRemoveHandler(agentService)); + router.post('/queue/clear', createQueueClearHandler(agentService)); + return router; } diff --git a/apps/server/src/routes/agent/routes/queue-add.ts b/apps/server/src/routes/agent/routes/queue-add.ts new file mode 100644 index 00000000..697f51c3 --- /dev/null +++ b/apps/server/src/routes/agent/routes/queue-add.ts @@ -0,0 +1,34 @@ +/** + * POST /queue/add endpoint - Add a prompt to the queue + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueAddHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, imagePaths, model } = req.body as { + sessionId: string; + message: string; + imagePaths?: string[]; + model?: string; + }; + + if (!sessionId || !message) { + res.status(400).json({ + success: false, + error: 'sessionId and message are required', + }); + return; + } + + const result = await agentService.addToQueue(sessionId, { message, imagePaths, model }); + res.json(result); + } catch (error) { + logError(error, 'Add to queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/queue-clear.ts b/apps/server/src/routes/agent/routes/queue-clear.ts new file mode 100644 index 00000000..34969eab --- /dev/null +++ b/apps/server/src/routes/agent/routes/queue-clear.ts @@ -0,0 +1,29 @@ +/** + * POST /queue/clear endpoint - Clear all prompts from the queue + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueClearHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required', + }); + return; + } + + const result = await agentService.clearQueue(sessionId); + res.json(result); + } catch (error) { + logError(error, 'Clear queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/queue-list.ts b/apps/server/src/routes/agent/routes/queue-list.ts new file mode 100644 index 00000000..1096c701 --- /dev/null +++ b/apps/server/src/routes/agent/routes/queue-list.ts @@ -0,0 +1,29 @@ +/** + * POST /queue/list endpoint - List queued prompts + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueListHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required', + }); + return; + } + + const result = agentService.getQueue(sessionId); + res.json(result); + } catch (error) { + logError(error, 'List queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/queue-remove.ts b/apps/server/src/routes/agent/routes/queue-remove.ts new file mode 100644 index 00000000..b2ed43d8 --- /dev/null +++ b/apps/server/src/routes/agent/routes/queue-remove.ts @@ -0,0 +1,32 @@ +/** + * POST /queue/remove endpoint - Remove a prompt from the queue + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueRemoveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, promptId } = req.body as { + sessionId: string; + promptId: string; + }; + + if (!sessionId || !promptId) { + res.status(400).json({ + success: false, + error: 'sessionId and promptId are required', + }); + return; + } + + const result = await agentService.removeFromQueue(sessionId, promptId); + res.json(result); + } catch (error) { + logError(error, 'Remove from queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/common.ts b/apps/server/src/routes/backlog-plan/common.ts new file mode 100644 index 00000000..74fb44c8 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/common.ts @@ -0,0 +1,39 @@ +/** + * Common utilities for backlog plan routes + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('BacklogPlan'); + +// State for tracking running generation +let isRunning = false; +let currentAbortController: AbortController | null = null; + +export function getBacklogPlanStatus(): { isRunning: boolean } { + return { isRunning }; +} + +export function setRunningState(running: boolean, abortController?: AbortController | null): void { + isRunning = running; + if (abortController !== undefined) { + currentAbortController = abortController; + } +} + +export function getAbortController(): AbortController | null { + return currentAbortController; +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function logError(error: unknown, context: string): void { + logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error)); +} + +export { logger }; diff --git a/apps/server/src/routes/backlog-plan/generate-plan.ts b/apps/server/src/routes/backlog-plan/generate-plan.ts new file mode 100644 index 00000000..737f4222 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -0,0 +1,217 @@ +/** + * Generate backlog plan using Claude AI + */ + +import type { EventEmitter } from '../../lib/events.js'; +import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import { ProviderFactory } from '../../providers/provider-factory.js'; +import { logger, setRunningState, getErrorMessage } from './common.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js'; + +const featureLoader = new FeatureLoader(); + +/** + * Format features for the AI prompt + */ +function formatFeaturesForPrompt(features: Feature[]): string { + if (features.length === 0) { + return 'No features in backlog yet.'; + } + + return features + .map((f) => { + const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : ''; + const priority = f.priority !== undefined ? `Priority: ${f.priority}` : ''; + return `- ID: ${f.id} + Title: ${f.title || 'Untitled'} + Description: ${f.description} + Category: ${f.category} + Status: ${f.status || 'backlog'} + ${priority} + ${deps}`.trim(); + }) + .join('\n\n'); +} + +/** + * Parse the AI response into a BacklogPlanResult + */ +function parsePlanResponse(response: string): BacklogPlanResult { + try { + // Try to extract JSON from the response + const jsonMatch = response.match(/```json\n?([\s\S]*?)\n?```/); + if (jsonMatch) { + return JSON.parse(jsonMatch[1]); + } + + // Try to parse the whole response as JSON + return JSON.parse(response); + } catch { + // If parsing fails, return an empty result + logger.warn('[BacklogPlan] Failed to parse AI response as JSON'); + return { + changes: [], + summary: 'Failed to parse AI response', + dependencyUpdates: [], + }; + } +} + +/** + * Generate a backlog modification plan based on user prompt + */ +export async function generateBacklogPlan( + projectPath: string, + prompt: string, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService, + model?: string +): Promise { + try { + // Load current features + const features = await featureLoader.getAll(projectPath); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: `Loaded ${features.length} features from backlog`, + }); + + // Build the system prompt + const systemPrompt = `You are an AI assistant helping to modify a software project's feature backlog. +You will be given the current list of features and a user request to modify the backlog. + +IMPORTANT CONTEXT (automatically injected): +- Remember to update the dependency graph if deleting existing features +- Remember to define dependencies on new features hooked into relevant existing ones +- Maintain dependency graph integrity (no orphaned dependencies) +- When deleting a feature, identify which other features depend on it + +Your task is to analyze the request and produce a structured JSON plan with: +1. Features to ADD (include title, description, category, and dependencies) +2. Features to UPDATE (specify featureId and the updates) +3. Features to DELETE (specify featureId) +4. A summary of the changes +5. Any dependency updates needed (removed dependencies due to deletions, new dependencies for new features) + +Respond with ONLY a JSON object in this exact format: +\`\`\`json +{ + "changes": [ + { + "type": "add", + "feature": { + "title": "Feature title", + "description": "Feature description", + "category": "Category name", + "dependencies": ["existing-feature-id"], + "priority": 1 + }, + "reason": "Why this feature should be added" + }, + { + "type": "update", + "featureId": "existing-feature-id", + "feature": { + "title": "Updated title" + }, + "reason": "Why this feature should be updated" + }, + { + "type": "delete", + "featureId": "feature-id-to-delete", + "reason": "Why this feature should be deleted" + } + ], + "summary": "Brief overview of all proposed changes", + "dependencyUpdates": [ + { + "featureId": "feature-that-depended-on-deleted", + "removedDependencies": ["deleted-feature-id"], + "addedDependencies": [] + } + ] +} +\`\`\``; + + // Build the user prompt + const userPrompt = `Current Features in Backlog: +${formatFeaturesForPrompt(features)} + +--- + +User Request: ${prompt} + +Please analyze the current backlog and the user's request, then provide a JSON plan for the modifications.`; + + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: 'Generating plan with AI...', + }); + + // Get the model to use + const effectiveModel = model || 'sonnet'; + const provider = ProviderFactory.getProviderForModel(effectiveModel); + + // Get autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[BacklogPlan]' + ); + + // Execute the query + const stream = provider.executeQuery({ + prompt: userPrompt, + model: effectiveModel, + cwd: projectPath, + systemPrompt, + maxTurns: 1, + allowedTools: [], // No tools needed for this + abortController, + settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined, + }); + + let responseText = ''; + + for await (const msg of stream) { + if (abortController.signal.aborted) { + throw new Error('Generation aborted'); + } + + if (msg.type === 'assistant') { + if (msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + } + } + } + } + } + + // Parse the response + const result = parsePlanResponse(responseText); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_complete', + result, + }); + + return result; + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error('[BacklogPlan] Generation failed:', errorMessage); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_error', + error: errorMessage, + }); + + throw error; + } finally { + setRunningState(false, null); + } +} diff --git a/apps/server/src/routes/backlog-plan/index.ts b/apps/server/src/routes/backlog-plan/index.ts new file mode 100644 index 00000000..393296df --- /dev/null +++ b/apps/server/src/routes/backlog-plan/index.ts @@ -0,0 +1,30 @@ +/** + * Backlog Plan routes - HTTP API for AI-assisted backlog modification + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createGenerateHandler } from './routes/generate.js'; +import { createStopHandler } from './routes/stop.js'; +import { createStatusHandler } from './routes/status.js'; +import { createApplyHandler } from './routes/apply.js'; +import type { SettingsService } from '../../services/settings-service.js'; + +export function createBacklogPlanRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { + const router = Router(); + + router.post( + '/generate', + validatePathParams('projectPath'), + createGenerateHandler(events, settingsService) + ); + router.post('/stop', createStopHandler()); + router.get('/status', createStatusHandler()); + router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); + + return router; +} diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts new file mode 100644 index 00000000..71dc3bd9 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -0,0 +1,147 @@ +/** + * POST /apply endpoint - Apply a backlog plan + */ + +import type { Request, Response } from 'express'; +import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError, logger } from '../common.js'; + +const featureLoader = new FeatureLoader(); + +export function createApplyHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, plan } = req.body as { + projectPath: string; + plan: BacklogPlanResult; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + if (!plan || !plan.changes) { + res.status(400).json({ success: false, error: 'plan with changes required' }); + return; + } + + const appliedChanges: string[] = []; + + // Load current features for dependency validation + const allFeatures = await featureLoader.getAll(projectPath); + const featureMap = new Map(allFeatures.map((f) => [f.id, f])); + + // Process changes in order: deletes first, then adds, then updates + // This ensures we can remove dependencies before they cause issues + + // 1. First pass: Handle deletes + const deletions = plan.changes.filter((c) => c.type === 'delete'); + for (const change of deletions) { + if (!change.featureId) continue; + + try { + // Before deleting, update any features that depend on this one + for (const feature of allFeatures) { + if (feature.dependencies?.includes(change.featureId)) { + const newDeps = feature.dependencies.filter((d) => d !== change.featureId); + await featureLoader.update(projectPath, feature.id, { dependencies: newDeps }); + logger.info( + `[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}` + ); + } + } + + // Now delete the feature + const deleted = await featureLoader.delete(projectPath, change.featureId); + if (deleted) { + appliedChanges.push(`deleted:${change.featureId}`); + featureMap.delete(change.featureId); + logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`); + } + } catch (error) { + logger.error( + `[BacklogPlan] Failed to delete ${change.featureId}:`, + getErrorMessage(error) + ); + } + } + + // 2. Second pass: Handle adds + const additions = plan.changes.filter((c) => c.type === 'add'); + for (const change of additions) { + if (!change.feature) continue; + + try { + // Create the new feature + const newFeature = await featureLoader.create(projectPath, { + title: change.feature.title, + description: change.feature.description || '', + category: change.feature.category || 'Uncategorized', + dependencies: change.feature.dependencies, + priority: change.feature.priority, + status: 'backlog', + }); + + appliedChanges.push(`added:${newFeature.id}`); + featureMap.set(newFeature.id, newFeature); + logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`); + } catch (error) { + logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error)); + } + } + + // 3. Third pass: Handle updates + const updates = plan.changes.filter((c) => c.type === 'update'); + for (const change of updates) { + if (!change.featureId || !change.feature) continue; + + try { + const updated = await featureLoader.update(projectPath, change.featureId, change.feature); + appliedChanges.push(`updated:${change.featureId}`); + featureMap.set(change.featureId, updated); + logger.info(`[BacklogPlan] Updated feature ${change.featureId}`); + } catch (error) { + logger.error( + `[BacklogPlan] Failed to update ${change.featureId}:`, + getErrorMessage(error) + ); + } + } + + // 4. Apply dependency updates from the plan + if (plan.dependencyUpdates) { + for (const depUpdate of plan.dependencyUpdates) { + try { + const feature = featureMap.get(depUpdate.featureId); + if (feature) { + const currentDeps = feature.dependencies || []; + const newDeps = currentDeps + .filter((d) => !depUpdate.removedDependencies.includes(d)) + .concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d))); + + await featureLoader.update(projectPath, depUpdate.featureId, { + dependencies: newDeps, + }); + logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`); + } + } catch (error) { + logger.error( + `[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`, + getErrorMessage(error) + ); + } + } + } + + res.json({ + success: true, + appliedChanges, + }); + } catch (error) { + logError(error, 'Apply backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/generate.ts b/apps/server/src/routes/backlog-plan/routes/generate.ts new file mode 100644 index 00000000..b596576f --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -0,0 +1,62 @@ +/** + * POST /generate endpoint - Generate a backlog plan + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getBacklogPlanStatus, setRunningState, getErrorMessage, logError } from '../common.js'; +import { generateBacklogPlan } from '../generate-plan.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, prompt, model } = req.body as { + projectPath: string; + prompt: string; + model?: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + if (!prompt) { + res.status(400).json({ success: false, error: 'prompt required' }); + return; + } + + const { isRunning } = getBacklogPlanStatus(); + if (isRunning) { + res.json({ + success: false, + error: 'Backlog plan generation is already running', + }); + return; + } + + setRunningState(true); + const abortController = new AbortController(); + setRunningState(true, abortController); + + // Start generation in background + generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model) + .catch((error) => { + logError(error, 'Generate backlog plan failed (background)'); + events.emit('backlog-plan:event', { + type: 'backlog_plan_error', + error: getErrorMessage(error), + }); + }) + .finally(() => { + setRunningState(false, null); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Generate backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/status.ts b/apps/server/src/routes/backlog-plan/routes/status.ts new file mode 100644 index 00000000..3b1684d3 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/status.ts @@ -0,0 +1,18 @@ +/** + * GET /status endpoint - Get backlog plan generation status + */ + +import type { Request, Response } from 'express'; +import { getBacklogPlanStatus, getErrorMessage, logError } from '../common.js'; + +export function createStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = getBacklogPlanStatus(); + res.json({ success: true, ...status }); + } catch (error) { + logError(error, 'Get backlog plan status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/backlog-plan/routes/stop.ts b/apps/server/src/routes/backlog-plan/routes/stop.ts new file mode 100644 index 00000000..aa275c67 --- /dev/null +++ b/apps/server/src/routes/backlog-plan/routes/stop.ts @@ -0,0 +1,22 @@ +/** + * POST /stop endpoint - Stop the current backlog plan generation + */ + +import type { Request, Response } from 'express'; +import { getAbortController, setRunningState, getErrorMessage, logError } from '../common.js'; + +export function createStopHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const abortController = getAbortController(); + if (abortController) { + abortController.abort(); + setRunningState(false, null); + } + res.json({ success: true }); + } catch (error) { + logError(error, 'Stop backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 323c23c8..5afddcd9 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -32,6 +32,14 @@ interface Message { isError?: boolean; } +interface QueuedPrompt { + id: string; + message: string; + imagePaths?: string[]; + model?: string; + addedAt: string; +} + interface Session { messages: Message[]; isRunning: boolean; @@ -39,6 +47,7 @@ interface Session { workingDirectory: string; model?: string; sdkSessionId?: string; // Claude SDK session ID for conversation continuity + promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task } interface SessionMetadata { @@ -94,12 +103,16 @@ export class AgentService { // Validate that the working directory is allowed using centralized validation validateWorkingDirectory(resolvedWorkingDirectory); + // Load persisted queue + const promptQueue = await this.loadQueueState(sessionId); + this.sessions.set(sessionId, { messages, isRunning: false, abortController: null, workingDirectory: resolvedWorkingDirectory, sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID + promptQueue, }); } @@ -178,6 +191,11 @@ export class AgentService { session.isRunning = true; session.abortController = new AbortController(); + // Emit started event so UI can show thinking indicator + this.emitAgentEvent(sessionId, { + type: 'started', + }); + // Emit user message event this.emitAgentEvent(sessionId, { type: 'message', @@ -336,6 +354,9 @@ export class AgentService { session.isRunning = false; session.abortController = null; + // Process next item in queue after completion + setImmediate(() => this.processNextInQueue(sessionId)); + return { success: true, message: currentAssistantMessage, @@ -574,6 +595,167 @@ export class AgentService { return true; } + // Queue management methods + + /** + * Add a prompt to the queue for later execution + */ + async addToQueue( + sessionId: string, + prompt: { message: string; imagePaths?: string[]; model?: string } + ): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + const queuedPrompt: QueuedPrompt = { + id: this.generateId(), + message: prompt.message, + imagePaths: prompt.imagePaths, + model: prompt.model, + addedAt: new Date().toISOString(), + }; + + session.promptQueue.push(queuedPrompt); + await this.saveQueueState(sessionId, session.promptQueue); + + // Emit queue update event + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + return { success: true, queuedPrompt }; + } + + /** + * Get the current queue for a session + */ + getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + return { success: true, queue: session.promptQueue }; + } + + /** + * Remove a specific prompt from the queue + */ + async removeFromQueue( + sessionId: string, + promptId: string + ): Promise<{ success: boolean; error?: string }> { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + const index = session.promptQueue.findIndex((p) => p.id === promptId); + if (index === -1) { + return { success: false, error: 'Prompt not found in queue' }; + } + + session.promptQueue.splice(index, 1); + await this.saveQueueState(sessionId, session.promptQueue); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + return { success: true }; + } + + /** + * Clear all prompts from the queue + */ + async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> { + const session = this.sessions.get(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + session.promptQueue = []; + await this.saveQueueState(sessionId, []); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: [], + }); + + return { success: true }; + } + + /** + * Save queue state to disk for persistence + */ + private async saveQueueState(sessionId: string, queue: QueuedPrompt[]): Promise { + const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); + try { + await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8'); + } catch (error) { + console.error('[AgentService] Failed to save queue state:', error); + } + } + + /** + * Load queue state from disk + */ + private async loadQueueState(sessionId: string): Promise { + const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); + try { + const data = (await secureFs.readFile(queueFile, 'utf-8')) as string; + return JSON.parse(data); + } catch { + return []; + } + } + + /** + * Process the next item in the queue (called after task completion) + */ + private async processNextInQueue(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || session.promptQueue.length === 0) { + return; + } + + // Don't process if already running + if (session.isRunning) { + return; + } + + const nextPrompt = session.promptQueue.shift(); + if (!nextPrompt) return; + + await this.saveQueueState(sessionId, session.promptQueue); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + console.log(`[AgentService] Processing next queued prompt for session ${sessionId}`); + + try { + await this.sendMessage({ + sessionId, + message: nextPrompt.message, + imagePaths: nextPrompt.imagePaths, + model: nextPrompt.model, + }); + } catch (error) { + console.error('[AgentService] Failed to process queued prompt:', error); + this.emitAgentEvent(sessionId, { + type: 'queue_error', + error: (error as Error).message, + promptId: nextPrompt.id, + }); + } + } + private emitAgentEvent(sessionId: string, data: Record): void { this.events.emit('agent:stream', { sessionId, ...data }); } diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index ef2a5e0d..15abbcdc 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -106,9 +106,9 @@ describe('agent-service.ts', () => { }); expect(result.success).toBe(true); - // First call reads session file and metadata file (2 calls) + // First call reads session file, metadata file, and queue state file (3 calls) // Second call should reuse in-memory session (no additional calls) - expect(fs.readFile).toHaveBeenCalledTimes(2); + expect(fs.readFile).toHaveBeenCalledTimes(3); }); }); diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 293c11c3..950d77de 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -18,6 +18,7 @@ import { ChevronDown, FileText, Square, + ListOrdered, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useElectronAgent } from '@/hooks/use-electron-agent'; @@ -86,6 +87,10 @@ export function AgentView() { clearHistory, stopExecution, error: agentError, + serverQueue, + addToServerQueue, + removeFromServerQueue, + clearServerQueue, } = useElectronAgent({ sessionId: currentSessionId || '', workingDirectory: currentProject?.path, @@ -134,11 +139,7 @@ export function AgentView() { }, [currentProject?.path]); const handleSend = useCallback(async () => { - if ( - (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) || - isProcessing - ) - return; + if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return; const messageContent = input; const messageImages = selectedImages; @@ -149,8 +150,13 @@ export function AgentView() { setSelectedTextFiles([]); setShowImageDropZone(false); - await sendMessage(messageContent, messageImages, messageTextFiles); - }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]); + // If already processing, add to server queue instead + if (isProcessing) { + await addToServerQueue(messageContent, messageImages, messageTextFiles); + } else { + await sendMessage(messageContent, messageImages, messageTextFiles); + } + }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]); const handleImagesSelected = useCallback((images: ImageAttachment[]) => { setSelectedImages(images); @@ -536,41 +542,6 @@ export function AgentView() { {/* Status indicators & actions */}
- {/* Model Selector */} - - - - - - {CLAUDE_MODELS.map((model) => ( - setSelectedModel(model.id)} - className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} - data-testid={`model-option-${model.id}`} - > -
- {model.label} - {model.description} -
-
- ))} -
-
- {currentTool && (
@@ -760,10 +731,52 @@ export function AgentView() { images={selectedImages} maxFiles={5} className="mb-4" - disabled={isProcessing || !isConnected} + disabled={!isConnected} /> )} + {/* Queued Prompts List */} + {serverQueue.length > 0 && ( +
+
+

+ {serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued +

+ +
+
+ {serverQueue.map((item, index) => ( +
+ + {index + 1}. + + {item.message} + {item.imagePaths && item.imagePaths.length > 0 && ( + + +{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''} + + )} + +
+ ))} +
+
+ )} + {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
@@ -778,7 +791,6 @@ export function AgentView() { setSelectedTextFiles([]); }} className="text-xs text-muted-foreground hover:text-foreground transition-colors" - disabled={isProcessing} > Clear all @@ -869,13 +881,17 @@ export function AgentView() { setInput(e.target.value)} onKeyPress={handleKeyPress} onPaste={handlePaste} - disabled={isProcessing || !isConnected} + disabled={!isConnected} data-testid="agent-input" className={cn( 'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', @@ -899,12 +915,44 @@ export function AgentView() { )}
+ {/* Model Selector */} + + + + + + {CLAUDE_MODELS.map((model) => ( + setSelectedModel(model.id)} + className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} + data-testid={`model-option-${model.id}`} + > +
+ {model.label} + {model.description} +
+
+ ))} +
+
+ {/* File Attachment Button */} - {/* Send / Stop Button */} - {isProcessing ? ( + {/* Stop Button (only when processing) */} + {isProcessing && ( - ) : ( - )} + + {/* Send / Queue Button */} +
{/* Keyboard hint */} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f54d8bc8..cfa063fd 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -9,7 +9,9 @@ import { import { useAppStore, Feature } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; import type { AutoModeEvent } from '@/types/electron'; +import type { BacklogPlanResult } from '@automaker/types'; import { pathsEqual } from '@/lib/utils'; +import { toast } from 'sonner'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; import { RefreshCw } from 'lucide-react'; @@ -25,6 +27,7 @@ import { GraphView } from './graph-view'; import { AddFeatureDialog, AgentOutputModal, + BacklogPlanDialog, CompletedFeaturesModal, ArchiveAllVerifiedDialog, DeleteCompletedFeatureDialog, @@ -125,6 +128,11 @@ export function BoardView() { } | null>(null); const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0); + // Backlog plan dialog state + const [showPlanDialog, setShowPlanDialog] = useState(false); + const [pendingBacklogPlan, setPendingBacklogPlan] = useState(null); + const [isGeneratingPlan, setIsGeneratingPlan] = useState(false); + // Follow-up state hook const { showFollowUpDialog, @@ -578,6 +586,37 @@ export function BoardView() { return unsubscribe; }, [currentProject]); + // Listen for backlog plan events (for background generation) + useEffect(() => { + const api = getElectronAPI(); + if (!api?.backlogPlan) return; + + const unsubscribe = api.backlogPlan.onEvent( + (event: { type: string; result?: BacklogPlanResult; error?: string }) => { + if (event.type === 'backlog_plan_complete') { + setIsGeneratingPlan(false); + if (event.result && event.result.changes?.length > 0) { + setPendingBacklogPlan(event.result); + toast.success('Plan ready! Click to review.', { + duration: 10000, + action: { + label: 'Review', + onClick: () => setShowPlanDialog(true), + }, + }); + } else { + toast.info('No changes generated. Try again with a different prompt.'); + } + } else if (event.type === 'backlog_plan_error') { + setIsGeneratingPlan(false); + toast.error(`Plan generation failed: ${event.error}`); + } + } + ); + + return unsubscribe; + }, []); + useEffect(() => { if (!autoMode.isRunning || !currentProject) { return; @@ -935,6 +974,7 @@ export function BoardView() { } }} onAddFeature={() => setShowAddDialog(true)} + onOpenPlanDialog={() => setShowPlanDialog(true)} addFeatureShortcut={{ key: shortcuts.addFeature, action: () => setShowAddDialog(true), @@ -1172,6 +1212,18 @@ export function BoardView() { setIsGenerating={setIsGeneratingSuggestions} /> + {/* Backlog Plan Dialog */} + setShowPlanDialog(false)} + projectPath={currentProject.path} + onPlanApplied={loadFeatures} + pendingPlanResult={pendingBacklogPlan} + setPendingPlanResult={setPendingBacklogPlan} + isGeneratingPlan={isGeneratingPlan} + setIsGeneratingPlan={setIsGeneratingPlan} + /> + {/* Plan Approval Dialog */} void; onAddFeature: () => void; + onOpenPlanDialog: () => void; addFeatureShortcut: KeyboardShortcut; isMounted: boolean; } @@ -27,6 +29,7 @@ export function BoardHeader({ isAutoModeRunning, onAutoModeToggle, onAddFeature, + onOpenPlanDialog, addFeatureShortcut, isMounted, }: BoardHeaderProps) { @@ -89,6 +92,16 @@ export function BoardHeader({
)} + + void; + projectPath: string; + onPlanApplied?: () => void; + // Props for background generation + pendingPlanResult: BacklogPlanResult | null; + setPendingPlanResult: (result: BacklogPlanResult | null) => void; + isGeneratingPlan: boolean; + setIsGeneratingPlan: (generating: boolean) => void; +} + +type DialogMode = 'input' | 'review' | 'applying'; + +export function BacklogPlanDialog({ + open, + onClose, + projectPath, + onPlanApplied, + pendingPlanResult, + setPendingPlanResult, + isGeneratingPlan, + setIsGeneratingPlan, +}: BacklogPlanDialogProps) { + const [mode, setMode] = useState('input'); + const [prompt, setPrompt] = useState(''); + const [expandedChanges, setExpandedChanges] = useState>(new Set()); + const [selectedChanges, setSelectedChanges] = useState>(new Set()); + + // Set mode based on whether we have a pending result + useEffect(() => { + if (open) { + if (pendingPlanResult) { + setMode('review'); + // Select all changes by default + setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i))); + setExpandedChanges(new Set()); + } else { + setMode('input'); + } + } + }, [open, pendingPlanResult]); + + const handleGenerate = useCallback(async () => { + if (!prompt.trim()) { + toast.error('Please enter a prompt describing the changes you want'); + return; + } + + const api = getElectronAPI(); + if (!api?.backlogPlan) { + toast.error('API not available'); + return; + } + + // Start generation in background + setIsGeneratingPlan(true); + + const result = await api.backlogPlan.generate(projectPath, prompt); + if (!result.success) { + setIsGeneratingPlan(false); + toast.error(result.error || 'Failed to start plan generation'); + return; + } + + // Show toast and close dialog - generation runs in background + toast.info('Generating plan... This will be ready soon!', { + duration: 3000, + }); + setPrompt(''); + onClose(); + }, [projectPath, prompt, setIsGeneratingPlan, onClose]); + + const handleApply = useCallback(async () => { + if (!pendingPlanResult) return; + + // Filter to only selected changes + const selectedChangesList = pendingPlanResult.changes.filter((_, index) => + selectedChanges.has(index) + ); + + if (selectedChangesList.length === 0) { + toast.error('Please select at least one change to apply'); + return; + } + + const api = getElectronAPI(); + if (!api?.backlogPlan) { + toast.error('API not available'); + return; + } + + setMode('applying'); + + // Create a filtered plan result with only selected changes + const filteredPlanResult: BacklogPlanResult = { + ...pendingPlanResult, + changes: selectedChangesList, + // Filter dependency updates to only include those for selected features + dependencyUpdates: + pendingPlanResult.dependencyUpdates?.filter((update) => { + const isDeleting = selectedChangesList.some( + (c) => c.type === 'delete' && c.featureId === update.featureId + ); + return !isDeleting; + }) || [], + }; + + const result = await api.backlogPlan.apply(projectPath, filteredPlanResult); + if (result.success) { + toast.success(`Applied ${result.appliedChanges?.length || 0} changes`); + setPendingPlanResult(null); + onPlanApplied?.(); + onClose(); + } else { + toast.error(result.error || 'Failed to apply plan'); + setMode('review'); + } + }, [ + projectPath, + pendingPlanResult, + selectedChanges, + setPendingPlanResult, + onPlanApplied, + onClose, + ]); + + const handleDiscard = useCallback(() => { + setPendingPlanResult(null); + setMode('input'); + }, [setPendingPlanResult]); + + const toggleChangeExpanded = (index: number) => { + setExpandedChanges((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + const toggleChangeSelected = (index: number) => { + setSelectedChanges((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + const toggleAllChanges = () => { + if (!pendingPlanResult) return; + if (selectedChanges.size === pendingPlanResult.changes.length) { + setSelectedChanges(new Set()); + } else { + setSelectedChanges(new Set(pendingPlanResult.changes.map((_, i) => i))); + } + }; + + const getChangeIcon = (type: BacklogChange['type']) => { + switch (type) { + case 'add': + return ; + case 'update': + return ; + case 'delete': + return ; + } + }; + + const getChangeLabel = (change: BacklogChange) => { + switch (change.type) { + case 'add': + return change.feature?.title || 'New Feature'; + case 'update': + return `Update: ${change.featureId}`; + case 'delete': + return `Delete: ${change.featureId}`; + } + }; + + const renderContent = () => { + switch (mode) { + case 'input': + return ( +
+
+ Describe the changes you want to make to your backlog. The AI will analyze your + current features and propose additions, updates, or deletions. +
+