diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index df412dc6..7ef1aabe 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('SpecRegeneration'); -// Shared state for tracking generation status - private -let isRunning = false; -let currentAbortController: AbortController | null = null; +// Shared state for tracking generation status - scoped by project path +const runningProjects = new Map(); +const abortControllers = new Map(); /** - * Get the current running state + * Get the running state for a specific project */ -export function getSpecRegenerationStatus(): { +export function getSpecRegenerationStatus(projectPath?: string): { isRunning: boolean; currentAbortController: AbortController | null; + projectPath?: string; } { - return { isRunning, currentAbortController }; + if (projectPath) { + return { + isRunning: runningProjects.get(projectPath) || false, + currentAbortController: abortControllers.get(projectPath) || null, + projectPath, + }; + } + // Fallback: check if any project is running (for backward compatibility) + const isAnyRunning = Array.from(runningProjects.values()).some((running) => running); + return { isRunning: isAnyRunning, currentAbortController: null }; } /** - * Set the running state and abort controller + * Get the project path that is currently running (if any) */ -export function setRunningState(running: boolean, controller: AbortController | null = null): void { - isRunning = running; - currentAbortController = controller; +export function getRunningProjectPath(): string | null { + for (const [path, running] of runningProjects.entries()) { + if (running) return path; + } + return null; +} + +/** + * Set the running state and abort controller for a specific project + */ +export function setRunningState( + projectPath: string, + running: boolean, + controller: AbortController | null = null +): void { + if (running) { + runningProjects.set(projectPath, true); + if (controller) { + abortControllers.set(projectPath, controller); + } + } else { + runningProjects.delete(projectPath); + abortControllers.delete(projectPath); + } } /** diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index ed6f68f1..31836867 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) { return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); // Start generation in background @@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) { }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index 0c80a9b6..dc627964 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler( return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Generation already running for this project' }); return; } logAuthStatus('Before starting feature generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background feature generation task...'); generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) @@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler( }) .finally(() => { logger.info('Feature generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/generate.ts b/apps/server/src/routes/app-spec/routes/generate.ts index a03dacb7..ffc792ae 100644 --- a/apps/server/src/routes/app-spec/routes/generate.ts +++ b/apps/server/src/routes/app-spec/routes/generate.ts @@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se return; } - const { isRunning } = getSpecRegenerationStatus(); + const { isRunning } = getSpecRegenerationStatus(projectPath); if (isRunning) { - logger.warn('Generation already running, rejecting request'); - res.json({ success: false, error: 'Spec generation already running' }); + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); return; } logAuthStatus('Before starting generation'); const abortController = new AbortController(); - setRunningState(true, abortController); + setRunningState(projectPath, true, abortController); logger.info('Starting background generation task...'); generateSpec( @@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se }) .finally(() => { logger.info('Generation task finished (success or error)'); - setRunningState(false, null); + setRunningState(projectPath, false, null); }); logger.info('Returning success response (generation running in background)'); diff --git a/apps/server/src/routes/app-spec/routes/status.ts b/apps/server/src/routes/app-spec/routes/status.ts index 542dd4f3..34caea32 100644 --- a/apps/server/src/routes/app-spec/routes/status.ts +++ b/apps/server/src/routes/app-spec/routes/status.ts @@ -6,10 +6,11 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; export function createStatusHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { isRunning } = getSpecRegenerationStatus(); - res.json({ success: true, isRunning }); + const projectPath = req.query.projectPath as string | undefined; + const { isRunning } = getSpecRegenerationStatus(projectPath); + res.json({ success: true, isRunning, projectPath }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/app-spec/routes/stop.ts b/apps/server/src/routes/app-spec/routes/stop.ts index 0751147b..2a7b0aab 100644 --- a/apps/server/src/routes/app-spec/routes/stop.ts +++ b/apps/server/src/routes/app-spec/routes/stop.ts @@ -6,13 +6,16 @@ import type { Request, Response } from 'express'; import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; export function createStopHandler() { - return async (_req: Request, res: Response): Promise => { + return async (req: Request, res: Response): Promise => { try { - const { currentAbortController } = getSpecRegenerationStatus(); + const { projectPath } = req.body as { projectPath?: string }; + const { currentAbortController } = getSpecRegenerationStatus(projectPath); if (currentAbortController) { currentAbortController.abort(); } - setRunningState(false, null); + if (projectPath) { + setRunningState(projectPath, false, null); + } res.json({ success: true }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts index 5f36d691..16dbd197 100644 --- a/apps/server/src/routes/auto-mode/index.ts +++ b/apps/server/src/routes/auto-mode/index.ts @@ -17,6 +17,7 @@ import { createAnalyzeProjectHandler } from './routes/analyze-project.js'; import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; import { createCommitFeatureHandler } from './routes/commit-feature.js'; import { createApprovePlanHandler } from './routes/approve-plan.js'; +import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; export function createAutoModeRoutes(autoModeService: AutoModeService): Router { const router = Router(); @@ -63,6 +64,11 @@ export function createAutoModeRoutes(autoModeService: AutoModeService): Router { validatePathParams('projectPath'), createApprovePlanHandler(autoModeService) ); + router.post( + '/resume-interrupted', + validatePathParams('projectPath'), + createResumeInterruptedHandler(autoModeService) + ); return router; } diff --git a/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts new file mode 100644 index 00000000..36cda2bd --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -0,0 +1,42 @@ +/** + * Resume Interrupted Features Handler + * + * Checks for features that were interrupted (in pipeline steps or in_progress) + * when the server was restarted and resumes them. + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeService } from '../../../services/auto-mode-service.js'; + +const logger = createLogger('ResumeInterrupted'); + +interface ResumeInterruptedRequest { + projectPath: string; +} + +export function createResumeInterruptedHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ResumeInterruptedRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Checking for interrupted features in ${projectPath}`); + + try { + await autoModeService.resumeInterruptedFeatures(projectPath); + res.json({ + success: true, + message: 'Resume check completed', + }); + } catch (error) { + logger.error('Error resuming interrupted features:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 4c3a9da4..2331fdd4 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -188,6 +188,7 @@ export function createEnhanceHandler( technical: prompts.enhancement.technicalSystemPrompt, simplify: prompts.enhancement.simplifySystemPrompt, acceptance: prompts.enhancement.acceptanceSystemPrompt, + 'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt, }; const systemPrompt = systemPromptMap[validMode]; diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index 4f62ee17..e0435f35 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -10,6 +10,7 @@ import { createGetHandler } from './routes/get.js'; import { createCreateHandler } from './routes/create.js'; import { createUpdateHandler } from './routes/update.js'; import { createBulkUpdateHandler } from './routes/bulk-update.js'; +import { createBulkDeleteHandler } from './routes/bulk-delete.js'; import { createDeleteHandler } from './routes/delete.js'; import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; import { createGenerateTitleHandler } from './routes/generate-title.js'; @@ -26,6 +27,11 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { validatePathParams('projectPath'), createBulkUpdateHandler(featureLoader) ); + router.post( + '/bulk-delete', + validatePathParams('projectPath'), + createBulkDeleteHandler(featureLoader) + ); router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); router.post('/agent-output', createAgentOutputHandler(featureLoader)); router.post('/raw-output', createRawOutputHandler(featureLoader)); diff --git a/apps/server/src/routes/features/routes/bulk-delete.ts b/apps/server/src/routes/features/routes/bulk-delete.ts new file mode 100644 index 00000000..6966d69a --- /dev/null +++ b/apps/server/src/routes/features/routes/bulk-delete.ts @@ -0,0 +1,62 @@ +/** + * POST /bulk-delete endpoint - Delete multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkDeleteRequest { + projectPath: string; + featureIds: string[]; +} + +interface BulkDeleteResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds } = req.body as BulkDeleteRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + const results: BulkDeleteResult[] = []; + + for (const featureId of featureIds) { + try { + const success = await featureLoader.delete(projectPath, featureId); + results.push({ featureId, success }); + } catch (error) { + results.push({ + featureId, + success: false, + error: getErrorMessage(error), + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + res.json({ + success: failureCount === 0, + deletedCount: successCount, + failedCount: failureCount, + results, + }); + } catch (error) { + logError(error, 'Bulk delete features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 2e960a62..9cb8f25e 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -16,7 +16,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { featureId: string; updates: Partial; descriptionHistorySource?: 'enhance' | 'edit'; - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'; + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'; }; if (!projectPath || !featureId || !updates) { diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index a2be666f..b830a297 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -31,7 +31,13 @@ import { const logger = createLogger('AutoMode'); import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; +import { + getFeatureDir, + getAutomakerDir, + getFeaturesDir, + getExecutionStatePath, + ensureAutomakerDir, +} from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -201,6 +207,29 @@ interface AutoModeConfig { projectPath: string; } +/** + * Execution state for recovery after server restart + * Tracks which features were running and auto-loop configuration + */ +interface ExecutionState { + version: 1; + autoLoopWasRunning: boolean; + maxConcurrency: number; + projectPath: string; + runningFeatureIds: string[]; + savedAt: string; +} + +// Default empty execution state +const DEFAULT_EXECUTION_STATE: ExecutionState = { + version: 1, + autoLoopWasRunning: false, + maxConcurrency: 3, + projectPath: '', + runningFeatureIds: [], + savedAt: '', +}; + // Constants for consecutive failure tracking const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive @@ -322,6 +351,9 @@ export class AutoModeService { projectPath, }); + // Save execution state for recovery after restart + await this.saveExecutionState(projectPath); + // Note: Memory folder initialization is now handled by loadContextFiles // Run the loop in the background @@ -390,17 +422,23 @@ export class AutoModeService { */ async stopAutoLoop(): Promise { const wasRunning = this.autoLoopRunning; + const projectPath = this.config?.projectPath; this.autoLoopRunning = false; if (this.autoLoopAbortController) { this.autoLoopAbortController.abort(); this.autoLoopAbortController = null; } + // Clear execution state when auto-loop is explicitly stopped + if (projectPath) { + await this.clearExecutionState(projectPath); + } + // Emit stop event immediately when user explicitly stops if (wasRunning) { this.emitAutoModeEvent('auto_mode_stopped', { message: 'Auto mode stopped', - projectPath: this.config?.projectPath, + projectPath, }); } @@ -441,6 +479,11 @@ export class AutoModeService { }; this.runningFeatures.set(featureId, tempRunningFeature); + // Save execution state when feature starts + if (isAutoMode) { + await this.saveExecutionState(projectPath); + } + try { // Validate that project path is allowed using centralized validation validateWorkingDirectory(projectPath); @@ -695,6 +738,11 @@ export class AutoModeService { `Pending approvals at cleanup: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` ); this.runningFeatures.delete(featureId); + + // Update execution state after feature completes + if (this.autoLoopRunning && projectPath) { + await this.saveExecutionState(projectPath); + } } } @@ -2950,6 +2998,149 @@ Begin implementing task ${task.id} now.`; }); } + // ============================================================================ + // Execution State Persistence - For recovery after server restart + // ============================================================================ + + /** + * Save execution state to disk for recovery after server restart + */ + private async saveExecutionState(projectPath: string): Promise { + try { + await ensureAutomakerDir(projectPath); + const statePath = getExecutionStatePath(projectPath); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: this.autoLoopRunning, + maxConcurrency: this.config?.maxConcurrency ?? 3, + projectPath, + runningFeatureIds: Array.from(this.runningFeatures.keys()), + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); + logger.info(`Saved execution state: ${state.runningFeatureIds.length} running features`); + } catch (error) { + logger.error('Failed to save execution state:', error); + } + } + + /** + * Load execution state from disk + */ + private async loadExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + const content = (await secureFs.readFile(statePath, 'utf-8')) as string; + const state = JSON.parse(content) as ExecutionState; + return state; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to load execution state:', error); + } + return DEFAULT_EXECUTION_STATE; + } + } + + /** + * Clear execution state (called on successful shutdown or when auto-loop stops) + */ + private async clearExecutionState(projectPath: string): Promise { + try { + const statePath = getExecutionStatePath(projectPath); + await secureFs.unlink(statePath); + logger.info('Cleared execution state'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to clear execution state:', error); + } + } + } + + /** + * Check for and resume interrupted features after server restart + * This should be called during server initialization + */ + async resumeInterruptedFeatures(projectPath: string): Promise { + logger.info('Checking for interrupted features to resume...'); + + // Load all features and find those that were interrupted + const featuresDir = getFeaturesDir(projectPath); + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + const interruptedFeatures: Feature[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + try { + const data = (await secureFs.readFile(featurePath, 'utf-8')) as string; + const feature = JSON.parse(data) as Feature; + + // Check if feature was interrupted (in_progress or pipeline_*) + if ( + feature.status === 'in_progress' || + (feature.status && feature.status.startsWith('pipeline_')) + ) { + // Verify it has existing context (agent-output.md) + const featureDir = getFeatureDir(projectPath, feature.id); + const contextPath = path.join(featureDir, 'agent-output.md'); + try { + await secureFs.access(contextPath); + interruptedFeatures.push(feature); + logger.info( + `Found interrupted feature: ${feature.id} (${feature.title}) - status: ${feature.status}` + ); + } catch { + // No context file, skip this feature - it will be restarted fresh + logger.info(`Interrupted feature ${feature.id} has no context, will restart fresh`); + } + } + } catch { + // Skip invalid features + } + } + } + + if (interruptedFeatures.length === 0) { + logger.info('No interrupted features found'); + return; + } + + logger.info(`Found ${interruptedFeatures.length} interrupted feature(s) to resume`); + + // Emit event to notify UI + this.emitAutoModeEvent('auto_mode_resuming_features', { + message: `Resuming ${interruptedFeatures.length} interrupted feature(s) after server restart`, + projectPath, + featureIds: interruptedFeatures.map((f) => f.id), + features: interruptedFeatures.map((f) => ({ + id: f.id, + title: f.title, + status: f.status, + })), + }); + + // Resume each interrupted feature + for (const feature of interruptedFeatures) { + try { + logger.info(`Resuming feature: ${feature.id} (${feature.title})`); + // Use resumeFeature which will detect the existing context and continue + await this.resumeFeature(projectPath, feature.id, true); + } catch (error) { + logger.error(`Failed to resume feature ${feature.id}:`, error); + // Continue with other features + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info('No features directory found, nothing to resume'); + } else { + logger.error('Error checking for interrupted features:', error); + } + } + } + /** * Extract and record learnings from a completed feature * Uses a quick Claude call to identify important decisions and patterns diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 93cff796..62570b6b 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -314,7 +314,7 @@ export class FeatureLoader { featureId: string, updates: Partial, descriptionHistorySource?: 'enhance' | 'edit', - enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer' ): Promise { const feature = await this.get(projectPath, featureId); if (!feature) { diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 2eff16c0..2933453a 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -126,6 +126,9 @@ export function Sidebar() { // Derive isCreatingSpec from store state const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; + // Check if the current project is specifically the one generating spec + const isCurrentProjectGeneratingSpec = + specCreatingForProject !== null && specCreatingForProject === currentProject?.path; // Auto-collapse sidebar on small screens and update Electron window minWidth useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); @@ -241,6 +244,7 @@ export function Sidebar() { cyclePrevProject, cycleNextProject, unviewedValidationsCount, + isSpecGenerating: isCurrentProjectGeneratingSpec, }); // Register keyboard shortcuts diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index 65b1bc13..825db5cd 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -1,4 +1,5 @@ import type { NavigateOptions } from '@tanstack/react-router'; +import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut } from '@/store/app-store'; import type { NavSection } from '../types'; @@ -80,14 +81,23 @@ export function SidebarNavigation({ data-testid={`nav-${item.id}`} >
- + {item.isLoading ? ( + + ) : ( + + )} {/* Count badge for collapsed state */} {!sidebarOpen && item.count !== undefined && item.count > 0 && ( { + isGeneratingRef.current = true; + onGenerateSpec(); + }; + return ( { - if (!isOpen) { + if (!isOpen && !isGeneratingRef.current) { + // Only call onSkip when user dismisses dialog (escape, click outside, or skip button) + // NOT when they click "Generate App Spec" onSkip(); } + isGeneratingRef.current = false; onOpenChange(isOpen); }} > @@ -108,7 +121,7 @@ export function OnboardingDialog({ Skip for now - - -

Kanban Board View

-
- - - - - - -

Dependency Graph View

-
-
-
- {/* Board Background Button */} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 091627ac..5a9b7302 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react'; import { UsagePopover } from '@/components/usage-popover'; -import { useAppStore, BoardViewMode } from '@/store/app-store'; +import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { getHttpApiClient } from '@/lib/http-api-client'; @@ -31,8 +31,6 @@ interface BoardHeaderProps { onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; - boardViewMode: BoardViewMode; - onBoardViewModeChange: (mode: BoardViewMode) => void; } // Shared styles for header control containers @@ -55,8 +53,6 @@ export function BoardHeader({ onShowBoardBackground, onShowCompletedModal, completedCount, - boardViewMode, - onBoardViewModeChange, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); @@ -117,8 +113,6 @@ export function BoardHeader({ onShowBoardBackground={onShowBoardBackground} onShowCompletedModal={onShowCompletedModal} completedCount={completedCount} - boardViewMode={boardViewMode} - onBoardViewModeChange={onBoardViewModeChange} />
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index b73a8d04..87268652 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -70,6 +70,7 @@ export function AgentInfoPanel({ }: AgentInfoPanelProps) { const [agentInfo, setAgentInfo] = useState(null); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); + const [isTodosExpanded, setIsTodosExpanded] = useState(false); useEffect(() => { const loadContext = async () => { @@ -197,32 +198,47 @@ export function AgentInfoPanel({ {agentInfo.todos.length} tasks
-
- {agentInfo.todos.slice(0, 3).map((todo, idx) => ( -
- {todo.status === 'completed' ? ( - - ) : todo.status === 'in_progress' ? ( - - ) : ( - - )} - + {(isTodosExpanded ? agentInfo.todos : agentInfo.todos.slice(0, 3)).map( + (todo, idx) => ( +
+ {todo.status === 'completed' ? ( + + ) : todo.status === 'in_progress' ? ( + + ) : ( + )} - > - {todo.content} - -
- ))} + + {todo.content} + +
+ ) + )} {agentInfo.todos.length > 3 && ( -

- +{agentInfo.todos.length - 3} more -

+ )}
diff --git a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx index 7f4a553a..7938d05e 100644 --- a/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx +++ b/apps/ui/src/components/views/board-view/components/selection-action-bar.tsx @@ -1,11 +1,21 @@ +import { useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Pencil, X, CheckSquare } from 'lucide-react'; +import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; interface SelectionActionBarProps { selectedCount: number; totalCount: number; onEdit: () => void; + onDelete: () => void; onClear: () => void; onSelectAll: () => void; } @@ -14,65 +24,126 @@ export function SelectionActionBar({ selectedCount, totalCount, onEdit, + onDelete, onClear, onSelectAll, }: SelectionActionBarProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + if (selectedCount === 0) return null; const allSelected = selectedCount === totalCount; + const handleDeleteClick = () => { + setShowDeleteDialog(true); + }; + + const handleConfirmDelete = () => { + setShowDeleteDialog(false); + onDelete(); + }; + return ( -
- - {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected - + <> +
+ + {selectedCount} feature{selectedCount !== 1 ? 's' : ''} selected + -
+
-
- +
+ - {!allSelected && ( - )} - + {!allSelected && ( + + )} + + +
-
+ + {/* Delete Confirmation Dialog */} + + + + + + Delete Selected Features? + + + Are you sure you want to permanently delete {selectedCount} feature + {selectedCount !== 1 ? 's' : ''}? + + This action cannot be undone. + + + + + + + + + + ); } diff --git a/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx new file mode 100644 index 00000000..c2eec445 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx @@ -0,0 +1,254 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import type { PipelineStep } from '@automaker/types'; +import { cn } from '@/lib/utils'; +import { STEP_TEMPLATES } from './pipeline-step-templates'; + +// Color options for pipeline columns +const COLOR_OPTIONS = [ + { value: 'bg-blue-500/20', label: 'Blue', preview: 'bg-blue-500' }, + { value: 'bg-purple-500/20', label: 'Purple', preview: 'bg-purple-500' }, + { value: 'bg-green-500/20', label: 'Green', preview: 'bg-green-500' }, + { value: 'bg-orange-500/20', label: 'Orange', preview: 'bg-orange-500' }, + { value: 'bg-red-500/20', label: 'Red', preview: 'bg-red-500' }, + { value: 'bg-pink-500/20', label: 'Pink', preview: 'bg-pink-500' }, + { value: 'bg-cyan-500/20', label: 'Cyan', preview: 'bg-cyan-500' }, + { value: 'bg-amber-500/20', label: 'Amber', preview: 'bg-amber-500' }, + { value: 'bg-indigo-500/20', label: 'Indigo', preview: 'bg-indigo-500' }, +]; + +interface AddEditPipelineStepDialogProps { + open: boolean; + onClose: () => void; + onSave: (step: Omit & { id?: string }) => void; + existingStep?: PipelineStep | null; + defaultOrder: number; +} + +export function AddEditPipelineStepDialog({ + open, + onClose, + onSave, + existingStep, + defaultOrder, +}: AddEditPipelineStepDialogProps) { + const isEditing = !!existingStep; + const fileInputRef = useRef(null); + + const [name, setName] = useState(''); + const [instructions, setInstructions] = useState(''); + const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value); + const [selectedTemplate, setSelectedTemplate] = useState(null); + + // Reset form when dialog opens/closes or existingStep changes + useEffect(() => { + if (open) { + if (existingStep) { + setName(existingStep.name); + setInstructions(existingStep.instructions); + setColorClass(existingStep.colorClass); + setSelectedTemplate(null); + } else { + setName(''); + setInstructions(''); + setColorClass(COLOR_OPTIONS[defaultOrder % COLOR_OPTIONS.length].value); + setSelectedTemplate(null); + } + } + }, [open, existingStep, defaultOrder]); + + const handleTemplateClick = (templateId: string) => { + const template = STEP_TEMPLATES.find((t) => t.id === templateId); + if (template) { + setName(template.name); + setInstructions(template.instructions); + setColorClass(template.colorClass); + setSelectedTemplate(templateId); + toast.success(`Loaded "${template.name}" template`); + } + }; + + const handleFileUpload = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const content = await file.text(); + setInstructions(content); + toast.success('Instructions loaded from file'); + } catch { + toast.error('Failed to load file'); + } + + // Reset the input so the same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSave = () => { + if (!name.trim()) { + toast.error('Step name is required'); + return; + } + + if (!instructions.trim()) { + toast.error('Step instructions are required'); + return; + } + + onSave({ + id: existingStep?.id, + name: name.trim(), + instructions: instructions.trim(), + colorClass, + order: existingStep?.order ?? defaultOrder, + }); + + onClose(); + }; + + return ( + !isOpen && onClose()}> + + {/* Hidden file input for loading instructions from .md files */} + + + + {isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'} + + {isEditing + ? 'Modify the step configuration below.' + : 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'} + + + +
+ {/* Template Quick Start - Only show for new steps */} + {!isEditing && ( +
+ +
+ {STEP_TEMPLATES.map((template) => ( + + ))} +
+

+ Click a template to pre-fill the form, then customize as needed. +

+
+ )} + + {/* Divider */} + {!isEditing &&
} + + {/* Step Name */} +
+ + setName(e.target.value)} + autoFocus={isEditing} + /> +
+ + {/* Color Selection */} +
+ +
+ {COLOR_OPTIONS.map((color) => ( +
+
+ + {/* Agent Instructions */} +
+
+ + +
+