mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
318
apps/ui/src/components/views/graph-view-page.tsx
Normal file
318
apps/ui/src/components/views/graph-view-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
6
apps/ui/src/routes/graph.tsx
Normal file
6
apps/ui/src/routes/graph.tsx
Normal 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,
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
5
apps/ui/src/types/electron.d.ts
vendored
5
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user