feat: enhance spec regeneration management by project

- 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.
This commit is contained in:
webdevcody
2026-01-11 01:37:26 -05:00
parent 7115460804
commit 5f3db1f25e
25 changed files with 890 additions and 588 deletions

View File

@@ -6,26 +6,57 @@ import { createLogger } from '@automaker/utils';
const logger = createLogger('SpecRegeneration'); const logger = createLogger('SpecRegeneration');
// Shared state for tracking generation status - private // Shared state for tracking generation status - scoped by project path
let isRunning = false; const runningProjects = new Map<string, boolean>();
let currentAbortController: AbortController | null = null; const abortControllers = new Map<string, AbortController>();
/** /**
* Get the current running state * Get the running state for a specific project
*/ */
export function getSpecRegenerationStatus(): { export function getSpecRegenerationStatus(projectPath?: string): {
isRunning: boolean; isRunning: boolean;
currentAbortController: AbortController | null; 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 { export function getRunningProjectPath(): string | null {
isRunning = running; for (const [path, running] of runningProjects.entries()) {
currentAbortController = controller; 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);
}
} }
/** /**

View File

@@ -47,17 +47,17 @@ export function createCreateHandler(events: EventEmitter) {
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Spec generation already running' }); res.json({ success: false, error: 'Spec generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting generation'); logAuthStatus('Before starting generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background generation task...'); logger.info('Starting background generation task...');
// Start generation in background // Start generation in background
@@ -80,7 +80,7 @@ export function createCreateHandler(events: EventEmitter) {
}) })
.finally(() => { .finally(() => {
logger.info('Generation task finished (success or error)'); logger.info('Generation task finished (success or error)');
setRunningState(false, null); setRunningState(projectPath, false, null);
}); });
logger.info('Returning success response (generation running in background)'); logger.info('Returning success response (generation running in background)');

View File

@@ -40,17 +40,17 @@ export function createGenerateFeaturesHandler(
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Generation already running' }); res.json({ success: false, error: 'Generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting feature generation'); logAuthStatus('Before starting feature generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background feature generation task...'); logger.info('Starting background feature generation task...');
generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService)
@@ -63,7 +63,7 @@ export function createGenerateFeaturesHandler(
}) })
.finally(() => { .finally(() => {
logger.info('Feature generation task finished (success or error)'); 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)'); logger.info('Returning success response (generation running in background)');

View File

@@ -48,17 +48,17 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return; return;
} }
const { isRunning } = getSpecRegenerationStatus(); const { isRunning } = getSpecRegenerationStatus(projectPath);
if (isRunning) { if (isRunning) {
logger.warn('Generation already running, rejecting request'); logger.warn('Generation already running for project:', projectPath);
res.json({ success: false, error: 'Spec generation already running' }); res.json({ success: false, error: 'Spec generation already running for this project' });
return; return;
} }
logAuthStatus('Before starting generation'); logAuthStatus('Before starting generation');
const abortController = new AbortController(); const abortController = new AbortController();
setRunningState(true, abortController); setRunningState(projectPath, true, abortController);
logger.info('Starting background generation task...'); logger.info('Starting background generation task...');
generateSpec( generateSpec(
@@ -81,7 +81,7 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
}) })
.finally(() => { .finally(() => {
logger.info('Generation task finished (success or error)'); logger.info('Generation task finished (success or error)');
setRunningState(false, null); setRunningState(projectPath, false, null);
}); });
logger.info('Returning success response (generation running in background)'); logger.info('Returning success response (generation running in background)');

View File

@@ -6,10 +6,11 @@ import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; import { getSpecRegenerationStatus, getErrorMessage } from '../common.js';
export function createStatusHandler() { export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { isRunning } = getSpecRegenerationStatus(); const projectPath = req.query.projectPath as string | undefined;
res.json({ success: true, isRunning }); const { isRunning } = getSpecRegenerationStatus(projectPath);
res.json({ success: true, isRunning, projectPath });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });
} }

View File

@@ -6,13 +6,16 @@ import type { Request, Response } from 'express';
import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js';
export function createStopHandler() { export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { currentAbortController } = getSpecRegenerationStatus(); const { projectPath } = req.body as { projectPath?: string };
const { currentAbortController } = getSpecRegenerationStatus(projectPath);
if (currentAbortController) { if (currentAbortController) {
currentAbortController.abort(); currentAbortController.abort();
} }
setRunningState(false, null); if (projectPath) {
setRunningState(projectPath, false, null);
}
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -126,6 +126,9 @@ export function Sidebar() {
// Derive isCreatingSpec from store state // Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null; const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject; 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 // Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
@@ -241,6 +244,7 @@ export function Sidebar() {
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
}); });
// Register keyboard shortcuts // Register keyboard shortcuts

View File

@@ -1,4 +1,5 @@
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types'; import type { NavSection } from '../types';
@@ -80,14 +81,23 @@ export function SidebarNavigation({
data-testid={`nav-${item.id}`} data-testid={`nav-${item.id}`}
> >
<div className="relative"> <div className="relative">
<Icon {item.isLoading ? (
className={cn( <Loader2
'w-[18px] h-[18px] shrink-0 transition-all duration-200', className={cn(
isActive 'w-[18px] h-[18px] shrink-0 animate-spin',
? 'text-brand-500 drop-shadow-sm' isActive ? 'text-brand-500' : 'text-muted-foreground'
: 'group-hover:text-brand-400 group-hover:scale-110' )}
)} />
/> ) : (
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
)}
{/* Count badge for collapsed state */} {/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && ( {!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span <span

View File

@@ -1,3 +1,4 @@
import { useRef } from 'react';
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react'; import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -24,13 +25,25 @@ export function OnboardingDialog({
onSkip, onSkip,
onGenerateSpec, onGenerateSpec,
}: OnboardingDialogProps) { }: OnboardingDialogProps) {
// Track if we're closing because user clicked "Generate App Spec"
// to avoid incorrectly calling onSkip
const isGeneratingRef = useRef(false);
const handleGenerateSpec = () => {
isGeneratingRef.current = true;
onGenerateSpec();
};
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
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(); onSkip();
} }
isGeneratingRef.current = false;
onOpenChange(isOpen); onOpenChange(isOpen);
}} }}
> >
@@ -108,7 +121,7 @@ export function OnboardingDialog({
Skip for now Skip for now
</Button> </Button>
<Button <Button
onClick={onGenerateSpec} onClick={handleGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0" className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
> >
<Sparkles className="w-4 h-4 mr-2" /> <Sparkles className="w-4 h-4 mr-2" />

View File

@@ -10,6 +10,7 @@ import {
GitPullRequest, GitPullRequest,
Lightbulb, Lightbulb,
Brain, Brain,
Network,
} from 'lucide-react'; } from 'lucide-react';
import type { NavSection, NavItem } from '../types'; import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -27,6 +28,7 @@ interface UseNavigationProps {
context: string; context: string;
memory: string; memory: string;
board: string; board: string;
graph: string;
agent: string; agent: string;
terminal: string; terminal: string;
settings: string; settings: string;
@@ -48,6 +50,8 @@ interface UseNavigationProps {
cycleNextProject: () => void; cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */ /** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number; unviewedValidationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
} }
export function useNavigation({ export function useNavigation({
@@ -65,6 +69,7 @@ export function useNavigation({
cyclePrevProject, cyclePrevProject,
cycleNextProject, cycleNextProject,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating,
}: UseNavigationProps) { }: UseNavigationProps) {
// Track if current project has a GitHub remote // Track if current project has a GitHub remote
const [hasGitHubRemote, setHasGitHubRemote] = useState(false); const [hasGitHubRemote, setHasGitHubRemote] = useState(false);
@@ -104,6 +109,7 @@ export function useNavigation({
label: 'Spec Editor', label: 'Spec Editor',
icon: FileText, icon: FileText,
shortcut: shortcuts.spec, shortcut: shortcuts.spec,
isLoading: isSpecGenerating,
}, },
{ {
id: 'context', id: 'context',
@@ -138,6 +144,12 @@ export function useNavigation({
icon: LayoutGrid, icon: LayoutGrid,
shortcut: shortcuts.board, shortcut: shortcuts.board,
}, },
{
id: 'graph',
label: 'Graph View',
icon: Network,
shortcut: shortcuts.graph,
},
{ {
id: 'agent', id: 'agent',
label: 'Agent Runner', label: 'Agent Runner',
@@ -197,6 +209,7 @@ export function useNavigation({
hideTerminal, hideTerminal,
hasGitHubRemote, hasGitHubRemote,
unviewedValidationsCount, unviewedValidationsCount,
isSpecGenerating,
]); ]);
// Build keyboard shortcuts for navigation // Build keyboard shortcuts for navigation

View File

@@ -13,6 +13,8 @@ export interface NavItem {
shortcut?: string; shortcut?: string;
/** Optional count badge to display next to the nav item */ /** Optional count badge to display next to the nav item */
count?: number; count?: number;
/** Whether this nav item is in a loading state (shows spinner) */
isLoading?: boolean;
} }
export interface SortableProjectItemProps { export interface SortableProjectItemProps {

View File

@@ -84,6 +84,7 @@ const KEYBOARD_ROWS = [
// Map shortcut names to human-readable labels // Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = { const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: 'Kanban Board', board: 'Kanban Board',
graph: 'Graph View',
agent: 'Agent Runner', agent: 'Agent Runner',
spec: 'Spec Editor', spec: 'Spec Editor',
context: 'Context', context: 'Context',
@@ -111,6 +112,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
// Categorize shortcuts for color coding // Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = { const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
board: 'navigation', board: 'navigation',
graph: 'navigation',
agent: 'navigation', agent: 'navigation',
spec: 'navigation', spec: 'navigation',
context: 'navigation', context: 'navigation',

View File

@@ -41,7 +41,6 @@ import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports // Board-view specific imports
import { BoardHeader } from './board-view/board-header'; import { BoardHeader } from './board-view/board-header';
import { KanbanBoard } from './board-view/kanban-board'; import { KanbanBoard } from './board-view/kanban-board';
import { GraphView } from './graph-view';
import { import {
AddFeatureDialog, AddFeatureDialog,
AgentOutputModal, AgentOutputModal,
@@ -88,8 +87,6 @@ export function BoardView() {
maxConcurrency, maxConcurrency,
setMaxConcurrency, setMaxConcurrency,
defaultSkipTests, defaultSkipTests,
boardViewMode,
setBoardViewMode,
specCreatingForProject, specCreatingForProject,
setSpecCreatingForProject, setSpecCreatingForProject,
pendingPlanApproval, pendingPlanApproval,
@@ -1174,8 +1171,6 @@ export function BoardView() {
onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length} completedCount={completedFeatures.length}
boardViewMode={boardViewMode}
onBoardViewModeChange={setBoardViewMode}
/> />
{/* Worktree Panel - conditionally rendered based on visibility setting */} {/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1214,69 +1209,46 @@ export function BoardView() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban or Graph */} {/* View Content - Kanban Board */}
{boardViewMode === 'kanban' ? ( <KanbanBoard
<KanbanBoard sensors={sensors}
sensors={sensors} collisionDetectionStrategy={collisionDetectionStrategy}
collisionDetectionStrategy={collisionDetectionStrategy} onDragStart={handleDragStart}
onDragStart={handleDragStart} onDragEnd={handleDragEnd}
onDragEnd={handleDragEnd} activeFeature={activeFeature}
activeFeature={activeFeature} getColumnFeatures={getColumnFeatures}
getColumnFeatures={getColumnFeatures} backgroundImageStyle={backgroundImageStyle}
backgroundImageStyle={backgroundImageStyle} backgroundSettings={backgroundSettings}
backgroundSettings={backgroundSettings} onEdit={(feature) => setEditingFeature(feature)}
onEdit={(feature) => setEditingFeature(feature)} onDelete={(featureId) => handleDeleteFeature(featureId)}
onDelete={(featureId) => handleDeleteFeature(featureId)} onViewOutput={handleViewOutput}
onViewOutput={handleViewOutput} onVerify={handleVerifyFeature}
onVerify={handleVerifyFeature} onResume={handleResumeFeature}
onResume={handleResumeFeature} onForceStop={handleForceStopFeature}
onForceStop={handleForceStopFeature} onManualVerify={handleManualVerify}
onManualVerify={handleManualVerify} onMoveBackToInProgress={handleMoveBackToInProgress}
onMoveBackToInProgress={handleMoveBackToInProgress} onFollowUp={handleOpenFollowUp}
onFollowUp={handleOpenFollowUp} onComplete={handleCompleteFeature}
onComplete={handleCompleteFeature} onImplement={handleStartImplementation}
onImplement={handleStartImplementation} onViewPlan={(feature) => setViewPlanFeature(feature)}
onViewPlan={(feature) => setViewPlanFeature(feature)} onApprovePlan={handleOpenApprovalDialog}
onApprovePlan={handleOpenApprovalDialog} onSpawnTask={(feature) => {
onSpawnTask={(feature) => { setSpawnParentFeature(feature);
setSpawnParentFeature(feature); setShowAddDialog(true);
setShowAddDialog(true); }}
}} featuresWithContext={featuresWithContext}
featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks}
runningAutoTasks={runningAutoTasks} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} onAddFeature={() => setShowAddDialog(true)}
onAddFeature={() => setShowAddDialog(true)} pipelineConfig={
pipelineConfig={ currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null }
} onOpenPipelineSettings={() => setShowPipelineSettings(true)}
onOpenPipelineSettings={() => setShowPipelineSettings(true)} isSelectionMode={isSelectionMode}
isSelectionMode={isSelectionMode} selectedFeatureIds={selectedFeatureIds}
selectedFeatureIds={selectedFeatureIds} onToggleFeatureSelection={toggleFeatureSelection}
onToggleFeatureSelection={toggleFeatureSelection} onToggleSelectionMode={toggleSelectionMode}
onToggleSelectionMode={toggleSelectionMode} />
/>
) : (
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
)}
</div> </div>
{/* Selection Action Bar */} {/* Selection Action Bar */}

View File

@@ -1,16 +1,12 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Columns3, Network } from 'lucide-react'; import { ImageIcon, Archive } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BoardViewMode } from '@/store/app-store';
interface BoardControlsProps { interface BoardControlsProps {
isMounted: boolean; isMounted: boolean;
onShowBoardBackground: () => void; onShowBoardBackground: () => void;
onShowCompletedModal: () => void; onShowCompletedModal: () => void;
completedCount: number; completedCount: number;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
} }
export function BoardControls({ export function BoardControls({
@@ -18,59 +14,12 @@ export function BoardControls({
onShowBoardBackground, onShowBoardBackground,
onShowCompletedModal, onShowCompletedModal,
completedCount, completedCount,
boardViewMode,
onBoardViewModeChange,
}: BoardControlsProps) { }: BoardControlsProps) {
if (!isMounted) return null; if (!isMounted) return null;
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* View Mode Toggle - Kanban / Graph */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="view-mode-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('kanban')}
className={cn(
'p-2 rounded-l-lg transition-colors',
boardViewMode === 'kanban'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-kanban"
>
<Columns3 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Kanban Board View</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onBoardViewModeChange('graph')}
className={cn(
'p-2 rounded-r-lg transition-colors',
boardViewMode === 'graph'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-mode-graph"
>
<Network className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Dependency Graph View</p>
</TooltipContent>
</Tooltip>
</div>
{/* Board Background Button */} {/* Board Background Button */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -6,7 +6,7 @@ import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react'; import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover'; 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 { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
@@ -31,8 +31,6 @@ interface BoardHeaderProps {
onShowBoardBackground: () => void; onShowBoardBackground: () => void;
onShowCompletedModal: () => void; onShowCompletedModal: () => void;
completedCount: number; completedCount: number;
boardViewMode: BoardViewMode;
onBoardViewModeChange: (mode: BoardViewMode) => void;
} }
// Shared styles for header control containers // Shared styles for header control containers
@@ -55,8 +53,6 @@ export function BoardHeader({
onShowBoardBackground, onShowBoardBackground,
onShowCompletedModal, onShowCompletedModal,
completedCount, completedCount,
boardViewMode,
onBoardViewModeChange,
}: BoardHeaderProps) { }: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys); const apiKeys = useAppStore((state) => state.apiKeys);
@@ -117,8 +113,6 @@ export function BoardHeader({
onShowBoardBackground={onShowBoardBackground} onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal} onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount} completedCount={completedCount}
boardViewMode={boardViewMode}
onBoardViewModeChange={onBoardViewModeChange}
/> />
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

View File

@@ -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<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => void;
existingStep?: PipelineStep | null;
defaultOrder: number;
}
export function AddEditPipelineStepDialog({
open,
onClose,
onSave,
existingStep,
defaultOrder,
}: AddEditPipelineStepDialogProps) {
const isEditing = !!existingStep;
const fileInputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState('');
const [instructions, setInstructions] = useState('');
const [colorClass, setColorClass] = useState(COLOR_OPTIONS[0].value);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Hidden file input for loading instructions from .md files */}
<input
ref={fileInputRef}
type="file"
accept=".md,.txt"
className="hidden"
onChange={handleFileInputChange}
/>
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit Pipeline Step' : 'Add Pipeline Step'}</DialogTitle>
<DialogDescription>
{isEditing
? 'Modify the step configuration below.'
: 'Configure a new step for your pipeline. Choose a template to get started quickly, or create from scratch.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-6">
{/* Template Quick Start - Only show for new steps */}
{!isEditing && (
<div className="space-y-3">
<Label className="text-sm font-medium">Quick Start from Template</Label>
<div className="flex flex-wrap gap-2">
{STEP_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
onClick={() => handleTemplateClick(template.id)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-all text-sm',
selectedTemplate === template.id
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
)}
>
<div
className={cn('w-2 h-2 rounded-full', template.colorClass.replace('/20', ''))}
/>
{template.name}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Click a template to pre-fill the form, then customize as needed.
</p>
</div>
)}
{/* Divider */}
{!isEditing && <div className="border-t" />}
{/* Step Name */}
<div className="space-y-2">
<Label htmlFor="step-name">
Step Name <span className="text-destructive">*</span>
</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus={isEditing}
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Column Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() => setColorClass(color.value)}
title={color.label}
/>
))}
</div>
</div>
{/* Agent Instructions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">
Agent Instructions <span className="text-destructive">*</span>
</Label>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleFileUpload}>
<Upload className="h-3 w-3 mr-1" />
Load from file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step. Use markdown formatting for best results."
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
These instructions will be sent to the agent when this step runs. Be specific about
what you want the agent to review, check, or modify.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>{isEditing ? 'Update Step' : 'Add to Pipeline'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -8,34 +8,11 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Plus, Trash2, ChevronUp, ChevronDown, Pencil } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Trash2, ChevronUp, ChevronDown, Upload, Pencil, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { PipelineConfig, PipelineStep } from '@automaker/types'; import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { STEP_TEMPLATES } from './pipeline-step-templates'; import { AddEditPipelineStepDialog } from './add-edit-pipeline-step-dialog';
// 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 PipelineSettingsDialogProps { interface PipelineSettingsDialogProps {
open: boolean; open: boolean;
@@ -45,14 +22,6 @@ interface PipelineSettingsDialogProps {
onSave: (config: PipelineConfig) => Promise<void>; onSave: (config: PipelineConfig) => Promise<void>;
} }
interface EditingStep {
id?: string;
name: string;
instructions: string;
colorClass: string;
order: number;
}
export function PipelineSettingsDialog({ export function PipelineSettingsDialog({
open, open,
onClose, onClose,
@@ -73,9 +42,11 @@ export function PipelineSettingsDialog({
}; };
const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps)); const [steps, setSteps] = useState<PipelineStep[]>(() => validateSteps(pipelineConfig?.steps));
const [editingStep, setEditingStep] = useState<EditingStep | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sub-dialog state
const [addEditDialogOpen, setAddEditDialogOpen] = useState(false);
const [editingStep, setEditingStep] = useState<PipelineStep | null>(null);
// Sync steps when dialog opens or pipelineConfig changes // Sync steps when dialog opens or pipelineConfig changes
useEffect(() => { useEffect(() => {
@@ -87,22 +58,13 @@ export function PipelineSettingsDialog({
const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); const sortedSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const handleAddStep = () => { const handleAddStep = () => {
setEditingStep({ setEditingStep(null);
name: '', setAddEditDialogOpen(true);
instructions: '',
colorClass: COLOR_OPTIONS[steps.length % COLOR_OPTIONS.length].value,
order: steps.length,
});
}; };
const handleEditStep = (step: PipelineStep) => { const handleEditStep = (step: PipelineStep) => {
setEditingStep({ setEditingStep(step);
id: step.id, setAddEditDialogOpen(true);
name: step.name,
instructions: step.instructions,
colorClass: step.colorClass,
order: step.order,
});
}; };
const handleDeleteStep = (stepId: string) => { const handleDeleteStep = (stepId: string) => {
@@ -134,53 +96,21 @@ export function PipelineSettingsDialog({
setSteps(newSteps); setSteps(newSteps);
}; };
const handleFileUpload = () => { const handleSaveStep = (
fileInputRef.current?.click(); stepData: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }
}; ) => {
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const content = await file.text();
setEditingStep((prev) => (prev ? { ...prev, instructions: content } : null));
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 handleSaveStep = () => {
if (!editingStep) return;
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString(); const now = new Date().toISOString();
if (editingStep.id) { if (stepData.id) {
// Update existing step // Update existing step
setSteps((prev) => setSteps((prev) =>
prev.map((s) => prev.map((s) =>
s.id === editingStep.id s.id === stepData.id
? { ? {
...s, ...s,
name: editingStep.name, name: stepData.name,
instructions: editingStep.instructions, instructions: stepData.instructions,
colorClass: editingStep.colorClass, colorClass: stepData.colorClass,
updatedAt: now, updatedAt: now,
} }
: s : s
@@ -190,90 +120,21 @@ export function PipelineSettingsDialog({
// Add new step // Add new step
const newStep: PipelineStep = { const newStep: PipelineStep = {
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name, name: stepData.name,
instructions: editingStep.instructions, instructions: stepData.instructions,
colorClass: editingStep.colorClass, colorClass: stepData.colorClass,
order: steps.length, order: steps.length,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
setSteps((prev) => [...prev, newStep]); setSteps((prev) => [...prev, newStep]);
} }
setEditingStep(null);
}; };
const handleSaveConfig = async () => { const handleSaveConfig = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// If the user is currently editing a step and clicks "Save Configuration", const sortedEffectiveSteps = [...steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// include that step in the config (common expectation) instead of silently dropping it.
let effectiveSteps = steps;
if (editingStep) {
if (!editingStep.name.trim()) {
toast.error('Step name is required');
return;
}
if (!editingStep.instructions.trim()) {
toast.error('Step instructions are required');
return;
}
const now = new Date().toISOString();
if (editingStep.id) {
// Update existing (or add if missing for some reason)
const existingIdx = effectiveSteps.findIndex((s) => s.id === editingStep.id);
if (existingIdx >= 0) {
effectiveSteps = effectiveSteps.map((s) =>
s.id === editingStep.id
? {
...s,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
updatedAt: now,
}
: s
);
} else {
effectiveSteps = [
...effectiveSteps,
{
id: editingStep.id,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
} else {
// Add new step
effectiveSteps = [
...effectiveSteps,
{
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`,
name: editingStep.name,
instructions: editingStep.instructions,
colorClass: editingStep.colorClass,
order: effectiveSteps.length,
createdAt: now,
updatedAt: now,
},
];
}
// Keep local UI state consistent with what we are saving.
setSteps(effectiveSteps);
setEditingStep(null);
}
const sortedEffectiveSteps = [...effectiveSteps].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0)
);
const config: PipelineConfig = { const config: PipelineConfig = {
version: 1, version: 1,
steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })), steps: sortedEffectiveSteps.map((s, index) => ({ ...s, order: index })),
@@ -289,259 +150,121 @@ export function PipelineSettingsDialog({
}; };
return ( return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}> <>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
{/* Hidden file input for loading instructions from .md files */} <DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<input <DialogHeader>
ref={fileInputRef} <DialogTitle>Pipeline Settings</DialogTitle>
type="file" <DialogDescription>
accept=".md,.txt" Configure custom pipeline steps that run after a feature completes "In Progress". Each
className="hidden" step will automatically prompt the agent with its instructions.
onChange={handleFileInputChange} </DialogDescription>
/> </DialogHeader>
<DialogHeader>
<DialogTitle>Pipeline Settings</DialogTitle>
<DialogDescription>
Configure custom pipeline steps that run after a feature completes "In Progress". Each
step will automatically prompt the agent with its instructions.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{/* Steps List */}
{sortedSteps.length > 0 ? (
<div className="space-y-2">
{sortedSteps.map((step, index) => (
<div <div
className={cn( key={step.id}
'w-3 h-8 rounded', className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
(step.colorClass || 'bg-blue-500/20').replace('/20', '') >
)} <div className="flex flex-col gap-1">
/> <Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === sortedSteps.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<div className="flex-1 min-w-0"> <div
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div> className={cn(
<div className="text-xs text-muted-foreground truncate"> 'w-3 h-8 rounded',
{(step.instructions || '').substring(0, 100)} (step.colorClass || 'bg-blue-500/20').replace('/20', '')
{(step.instructions || '').length > 100 ? '...' : ''} )}
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{step.name || 'Unnamed Step'}</div>
<div className="text-xs text-muted-foreground truncate">
{(step.instructions || '').substring(0, 100)}
{(step.instructions || '').length > 100 ? '...' : ''}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div> </div>
</div> </div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
<div className="flex items-center gap-1"> {/* Add Step Button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditStep(step)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteStep(step.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>No pipeline steps configured.</p>
<p className="text-sm">
Add steps to create a custom workflow after features complete.
</p>
</div>
)}
{/* Add Step Button */}
{!editingStep && (
<Button variant="outline" className="w-full" onClick={handleAddStep}> <Button variant="outline" className="w-full" onClick={handleAddStep}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Pipeline Step Add Pipeline Step
</Button> </Button>
)} </div>
{/* Edit/Add Step Form */} <DialogFooter>
{editingStep && ( <Button variant="outline" onClick={onClose}>
<div className="border rounded-lg p-4 space-y-4 bg-muted/20"> Cancel
<div className="flex items-center justify-between"> </Button>
<h4 className="font-medium">{editingStep.id ? 'Edit Step' : 'New Step'}</h4> <Button onClick={handleSaveConfig} disabled={isSubmitting}>
<Button {isSubmitting ? 'Saving...' : 'Save Pipeline'}
variant="ghost" </Button>
size="icon" </DialogFooter>
className="h-6 w-6" </DialogContent>
onClick={() => setEditingStep(null)} </Dialog>
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Template Selector - only show for new steps */} {/* Sub-dialog for adding/editing steps */}
{!editingStep.id && ( <AddEditPipelineStepDialog
<div className="space-y-2"> open={addEditDialogOpen}
<Label>Start from Template</Label> onClose={() => {
<Select setAddEditDialogOpen(false);
onValueChange={(templateId) => { setEditingStep(null);
const template = STEP_TEMPLATES.find((t) => t.id === templateId); }}
if (template) { onSave={handleSaveStep}
setEditingStep((prev) => existingStep={editingStep}
prev defaultOrder={steps.length}
? { />
...prev, </>
name: template.name,
instructions: template.instructions,
colorClass: template.colorClass,
}
: null
);
toast.success(`Loaded "${template.name}" template`);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a template (optional)" />
</SelectTrigger>
<SelectContent>
{STEP_TEMPLATES.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
template.colorClass.replace('/20', '')
)}
/>
{template.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select a pre-built template to populate the form, or create your own from
scratch.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="step-name">Step Name</Label>
<Input
id="step-name"
placeholder="e.g., Code Review, Testing, Documentation"
value={editingStep.name}
onChange={(e) =>
setEditingStep((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{COLOR_OPTIONS.map((color) => (
<button
key={color.value}
type="button"
className={cn(
'w-8 h-8 rounded-full transition-all',
color.preview,
editingStep.colorClass === color.value
? 'ring-2 ring-offset-2 ring-primary'
: 'opacity-60 hover:opacity-100'
)}
onClick={() =>
setEditingStep((prev) =>
prev ? { ...prev, colorClass: color.value } : null
)
}
title={color.label}
/>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="step-instructions">Agent Instructions</Label>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={handleFileUpload}
>
<Upload className="h-3 w-3 mr-1" />
Load from .md file
</Button>
</div>
<Textarea
id="step-instructions"
placeholder="Instructions for the agent to follow during this pipeline step..."
value={editingStep.instructions}
onChange={(e) =>
setEditingStep((prev) =>
prev ? { ...prev, instructions: e.target.value } : null
)
}
rows={6}
className="font-mono text-sm"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingStep(null)}>
Cancel
</Button>
<Button onClick={handleSaveStep}>
{editingStep.id ? 'Update Step' : 'Add Step'}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveConfig} disabled={isSubmitting}>
{isSubmitting
? 'Saving...'
: editingStep
? 'Save Step & Configuration'
: 'Save Configuration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }

View File

@@ -0,0 +1,318 @@
// @ts-nocheck
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { GraphView } from './graph-view';
import { EditFeatureDialog, AddFeatureDialog, AgentOutputModal } from './board-view/dialogs';
import {
useBoardFeatures,
useBoardActions,
useBoardBackground,
useBoardPersistence,
} from './board-view/hooks';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { pathsEqual } from '@/lib/utils';
import { RefreshCw } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('GraphViewPage');
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
export function GraphViewPage() {
const {
currentProject,
updateFeature,
getCurrentWorktree,
getWorktrees,
setWorktrees,
setCurrentWorktree,
defaultSkipTests,
} = useAppStore();
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
currentProject
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
// Load features
const {
features: hookFeatures,
isLoading,
persistedCategories,
loadFeatures,
saveCategory,
} = useBoardFeatures({ currentProject });
// Auto mode hook
const autoMode = useAutoMode();
const runningAutoTasks = autoMode.runningTasks;
// Search state
const [searchQuery, setSearchQuery] = useState('');
// Dialog states
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
// Worktree refresh key
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
// Get current worktree info
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Get the branch for the currently selected worktree
const selectedWorktree = useMemo(() => {
if (currentWorktreePath === null) {
return worktrees.find((w) => w.isMain);
} else {
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Branch suggestions
const [branchSuggestions, setBranchSuggestions] = useState<string[]>([]);
useEffect(() => {
const fetchBranches = async () => {
if (!currentProject) {
setBranchSuggestions([]);
return;
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
setBranchSuggestions([]);
return;
}
const result = await api.worktree.listBranches(currentProject.path);
if (result.success && result.result?.branches) {
const localBranches = result.result.branches
.filter((b) => !b.isRemote)
.map((b) => b.name);
setBranchSuggestions(localBranches);
}
} catch (error) {
logger.error('Error fetching branches:', error);
setBranchSuggestions([]);
}
};
fetchBranches();
}, [currentProject, worktreeRefreshKey]);
// Branch card counts
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);
// Category suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
}, [hookFeatures, persistedCategories]);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Follow-up state (simplified for graph view)
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<any[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<Map<string, string>>(new Map());
// In-progress features for shortcuts
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Board actions hook
const {
handleAddFeature,
handleUpdateFeature,
handleDeleteFeature,
handleStartImplementation,
handleResumeFeature,
handleViewOutput,
handleForceStopFeature,
handleOutputModalNumberKeyPress,
} = useBoardActions({
currentProject,
features: hookFeatures,
runningAutoTasks,
loadFeatures,
persistFeatureCreate,
persistFeatureUpdate,
persistFeatureDelete,
saveCategory,
setEditingFeature,
setShowOutputModal,
setOutputFeature,
followUpFeature,
followUpPrompt,
followUpImagePaths,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setShowFollowUpDialog: () => {},
inProgressFeaturesForShortcuts,
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
// Handle add and start feature
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
}
},
[handleAddFeature, handleStartImplementation]
);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg relative"
data-testid="graph-view-page"
>
{/* Graph View Content */}
<GraphView
features={hookFeatures}
runningAutoTasks={runningAutoTasks}
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
onUpdateFeature={updateFeature}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onDeleteTask={(feature) => handleDeleteFeature(feature.id)}
/>
{/* Edit Feature Dialog */}
<EditFeatureDialog
feature={editingFeature}
onClose={() => setEditingFeature(null)}
onUpdate={handleUpdateFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
allFeatures={hookFeatures}
/>
{/* Add Feature Dialog (for spawning) */}
<AddFeatureDialog
open={showAddDialog}
onOpenChange={(open) => {
setShowAddDialog(open);
if (!open) {
setSpawnParentFeature(null);
}
}}
onAdd={handleAddFeature}
onAddAndStart={handleAddAndStartFeature}
categorySuggestions={categorySuggestions}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
defaultSkipTests={defaultSkipTests}
defaultBranch={selectedWorktreeBranch}
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
/>
{/* Agent Output Modal */}
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
</div>
);
}

View File

@@ -81,7 +81,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
return; return;
} }
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
logger.debug( logger.debug(
'[useSpecGeneration] Status check on mount:', '[useSpecGeneration] Status check on mount:',
status, status,
@@ -90,9 +90,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
); );
if (status.success && status.isRunning) { if (status.success && status.isRunning) {
logger.debug( logger.debug('[useSpecGeneration] Spec generation is running for this project.');
'[useSpecGeneration] Spec generation is running globally. Tentatively showing loader.'
);
setIsCreating(true); setIsCreating(true);
setIsRegenerating(true); setIsRegenerating(true);
@@ -143,7 +141,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.specRegeneration) return; if (!api.specRegeneration) return;
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
logger.debug('[useSpecGeneration] Visibility change - status check:', status); logger.debug('[useSpecGeneration] Visibility change - status check:', status);
if (!status.isRunning) { if (!status.isRunning) {
@@ -180,7 +178,7 @@ export function useSpecGeneration({ loadSpec }: UseSpecGenerationOptions) {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api.specRegeneration) return; if (!api.specRegeneration) return;
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
if (!status.isRunning) { if (!status.isRunning) {
logger.debug( logger.debug(

View File

@@ -21,9 +21,9 @@ export function useSpecLoading() {
// Check if spec generation is running before trying to load // Check if spec generation is running before trying to load
// This prevents showing "No App Specification Found" during generation // This prevents showing "No App Specification Found" during generation
if (api.specRegeneration) { if (api.specRegeneration) {
const status = await api.specRegeneration.status(); const status = await api.specRegeneration.status(currentProject.path);
if (status.success && status.isRunning) { if (status.success && status.isRunning) {
logger.debug('Spec generation is running, skipping load'); logger.debug('Spec generation is running for this project, skipping load');
setIsGenerationRunning(true); setIsGenerationRunning(true);
setIsLoading(false); setIsLoading(false);
return; return;

View File

@@ -433,11 +433,12 @@ export interface SpecRegenerationAPI {
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
stop: () => Promise<{ success: boolean; error?: string }>; stop: (projectPath?: string) => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ status: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
currentPhase?: string; currentPhase?: string;
projectPath?: string;
error?: string; error?: string;
}>; }>;
onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void; onEvent: (callback: (event: SpecRegenerationEvent) => void) => () => void;
@@ -2506,7 +2507,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true }; return { success: true };
}, },
stop: async () => { stop: async (_projectPath?: string) => {
mockSpecRegenerationRunning = false; mockSpecRegenerationRunning = false;
mockSpecRegenerationPhase = ''; mockSpecRegenerationPhase = '';
if (mockSpecRegenerationTimeout) { if (mockSpecRegenerationTimeout) {
@@ -2516,7 +2517,7 @@ function createMockSpecRegenerationAPI(): SpecRegenerationAPI {
return { success: true }; return { success: true };
}, },
status: async () => { status: async (_projectPath?: string) => {
return { return {
success: true, success: true,
isRunning: mockSpecRegenerationRunning, isRunning: mockSpecRegenerationRunning,

View File

@@ -1670,8 +1670,13 @@ export class HttpApiClient implements ElectronAPI {
projectPath, projectPath,
maxFeatures, maxFeatures,
}), }),
stop: () => this.post('/api/spec-regeneration/stop'), stop: (projectPath?: string) => this.post('/api/spec-regeneration/stop', { projectPath }),
status: () => this.get('/api/spec-regeneration/status'), status: (projectPath?: string) =>
this.get(
projectPath
? `/api/spec-regeneration/status?projectPath=${encodeURIComponent(projectPath)}`
: '/api/spec-regeneration/status'
),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => { onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback); return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
}, },

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { GraphViewPage } from '@/components/views/graph-view-page';
export const Route = createFileRoute('/graph')({
component: GraphViewPage,
});

View File

@@ -210,6 +210,7 @@ export function formatShortcut(shortcut: string | undefined | null, forDisplay =
export interface KeyboardShortcuts { export interface KeyboardShortcuts {
// Navigation shortcuts // Navigation shortcuts
board: string; board: string;
graph: string;
agent: string; agent: string;
spec: string; spec: string;
context: string; context: string;
@@ -244,6 +245,7 @@ export interface KeyboardShortcuts {
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation // Navigation
board: 'K', board: 'K',
graph: 'H',
agent: 'A', agent: 'A',
spec: 'D', spec: 'D',
context: 'C', context: 'C',

View File

@@ -367,15 +367,16 @@ export interface SpecRegenerationAPI {
error?: string; error?: string;
}>; }>;
stop: () => Promise<{ stop: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>; }>;
status: () => Promise<{ status: (projectPath?: string) => Promise<{
success: boolean; success: boolean;
isRunning?: boolean; isRunning?: boolean;
currentPhase?: string; currentPhase?: string;
projectPath?: string;
error?: string; error?: string;
}>; }>;