From 5f3db1f25e79ad29748f46a9e0dcf388de1af0d1 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sun, 11 Jan 2026 01:37:26 -0500 Subject: [PATCH] feat: enhance spec regeneration management by project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored spec regeneration status tracking to support multiple projects using a Map for running states and abort controllers. - Updated `getSpecRegenerationStatus` to accept a project path, allowing retrieval of status specific to a project. - Modified `setRunningState` to manage running states and abort controllers per project. - Adjusted related route handlers to utilize project-specific status checks and updates. - Introduced a new Graph View page and integrated it into the routing structure. - Enhanced UI components to reflect the current project’s spec generation state. --- apps/server/src/routes/app-spec/common.ts | 51 +- .../src/routes/app-spec/routes/create.ts | 10 +- .../app-spec/routes/generate-features.ts | 10 +- .../src/routes/app-spec/routes/generate.ts | 10 +- .../src/routes/app-spec/routes/status.ts | 7 +- .../server/src/routes/app-spec/routes/stop.ts | 9 +- apps/ui/src/components/layout/sidebar.tsx | 4 + .../sidebar/components/sidebar-navigation.tsx | 26 +- .../sidebar/dialogs/onboarding-dialog.tsx | 17 +- .../layout/sidebar/hooks/use-navigation.ts | 13 + .../ui/src/components/layout/sidebar/types.ts | 2 + apps/ui/src/components/ui/keyboard-map.tsx | 2 + apps/ui/src/components/views/board-view.tsx | 108 ++-- .../views/board-view/board-controls.tsx | 53 +- .../views/board-view/board-header.tsx | 8 +- .../dialogs/add-edit-pipeline-step-dialog.tsx | 254 +++++++++ .../dialogs/pipeline-settings-dialog.tsx | 531 +++++------------- .../src/components/views/graph-view-page.tsx | 318 +++++++++++ .../spec-view/hooks/use-spec-generation.ts | 10 +- .../views/spec-view/hooks/use-spec-loading.ts | 4 +- apps/ui/src/lib/electron.ts | 9 +- apps/ui/src/lib/http-api-client.ts | 9 +- apps/ui/src/routes/graph.tsx | 6 + apps/ui/src/store/app-store.ts | 2 + apps/ui/src/types/electron.d.ts | 5 +- 25 files changed, 890 insertions(+), 588 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/dialogs/add-edit-pipeline-step-dialog.tsx create mode 100644 apps/ui/src/components/views/graph-view-page.tsx create mode 100644 apps/ui/src/routes/graph.tsx 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/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/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 */} +
+
+ + +
+